merge master

This commit is contained in:
Karol
2022-08-20 22:20:52 +02:00
291 changed files with 5413 additions and 9429 deletions

View File

@ -1 +0,0 @@
defaults

View File

@ -6,7 +6,7 @@ jobs:
runs-on: ubuntu-20.04
services:
db:
image: postgres:13
image: postgres:14
env:
POSTGRES_PASSWORD: postgres
ports:
@ -35,22 +35,27 @@ jobs:
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 2.7
ruby-version: 3.1
bundler-cache: true
- name: Setup Node
uses: actions/setup-node@v1
with:
node-version: 12
node-version: 18
- name: Get yarn cache directory path
id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn config get cacheFolder)"
- name: Manage yarn cache
uses: actions/cache@v2
run: echo "::set-output name=dir::$(yarn cache dir)"
- name: Manage yarn, webpack and assets cache
uses: actions/cache@v3
# use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
id: yarn-cache
with:
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
path: |
${{ steps.yarn-cache-dir-path.outputs.dir }}
public/assets
public/packs-test
tmp/cache
tmp/webpacker
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
@ -70,11 +75,16 @@ jobs:
env:
RAILS_ENV: test
run: bundler exec rake db:schema:load
- name: Precompile assets
env:
RAILS_ENV: test
run: bundler exec rake assets:precompile
- name: Run tests
env:
RAILS_ENV: test
CC_TEST_REPORTER_ID: true
run: bundle exec rspec --color --format progress --require spec_helper --require rails_helper
NODE_OPTIONS: --openssl-legacy-provider
run: bundle exec rspec --color --format RSpec::Github::Formatter --format progress --require spec_helper --require rails_helper
- name: Send coverage to CodeClimate
uses: paambaati/codeclimate-action@v3.0.0
@ -93,8 +103,16 @@ jobs:
- name: Setup Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: 2.7
ruby-version: 3.1
bundler-cache: true
- name: Run rubocop
run: bundle exec rubocop --parallel
uses: reviewdog/action-rubocop@v2
with:
filter_mode: nofilter
rubocop_version: gemfile
rubocop_extensions: rubocop-rails:gemfile rubocop-rspec:gemfile rubocop-performance:gemfile
rubocop_flags: --parallel
reporter: github-check
skip_install: true
use_bundler: true

View File

@ -31,7 +31,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@ -42,7 +42,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
uses: github/codeql-action/autobuild@v2
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@ -56,4 +56,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1
uses: github/codeql-action/analyze@v2

View File

@ -19,7 +19,7 @@ Metrics/ClassLength:
Max: 600
Metrics/ModuleLength:
Max: 220
Max: 225
# It's a very complicated application...
#

19
Gemfile
View File

@ -10,6 +10,7 @@ gem 'docker-api', require: 'docker'
gem 'eventmachine'
gem 'factory_bot_rails'
gem 'faraday'
gem 'faraday-net_http_persistent'
gem 'faye-websocket'
gem 'forgery'
gem 'highline'
@ -21,34 +22,36 @@ gem 'js-routes'
gem 'kramdown'
gem 'mimemagic'
gem 'net-http-persistent'
gem 'net-imap', require: false
gem 'net-pop', require: false
gem 'net-smtp', require: false
gem 'nokogiri'
gem 'pagedown-bootstrap-rails'
gem 'pg'
gem 'proforma', github: 'openHPI/proforma', tag: 'v0.7.1'
gem 'proforma', github: 'openHPI/proforma', branch: 'v0.5.2'
gem 'prometheus_exporter'
gem 'pry-byebug'
gem 'puma'
gem 'pundit'
gem 'rails', '~> 6.1.4'
gem 'rails_admin'
gem 'rails', '~> 6.1.6'
gem 'rails_admin', '< 3.0.0' # Blocked by https://github.com/railsadminteam/rails_admin/issues/3490
gem 'rails-i18n'
gem 'rails-timeago'
gem 'ransack'
gem 'rest-client'
gem 'rubytree', github: 'evolve75/RubyTree'
gem 'rubytree'
gem 'rubyzip'
gem 'sass-rails'
gem 'shakapacker', '6.5.1'
gem 'slim-rails'
gem 'sorcery' # Causes a deprecation warning in Rails 6.0+, see: https://github.com/Sorcery/sorcery/pull/255
gem 'telegraf'
gem 'tubesock', github: 'gosukiwi/tubesock', branch: 'patch-1' # Switch to a fork which is compatible with Rails 5
gem 'tubesock'
gem 'turbolinks'
gem 'webpacker'
gem 'whenever', require: false
# Error Tracing
gem 'mnemosyne-ruby'
gem 'newrelic_rpm'
gem 'sentry-rails'
gem 'sentry-ruby'
@ -56,6 +59,7 @@ group :development, :staging do
gem 'better_errors'
gem 'binding_of_caller'
gem 'bootsnap', require: false
gem 'letter_opener'
gem 'listen'
gem 'pry-rails'
gem 'rack-mini-profiler'
@ -80,6 +84,7 @@ group :test do
gem 'rails-controller-testing'
gem 'rspec-autotest'
gem 'rspec-collection_matchers'
gem 'rspec-github', require: false
gem 'rspec-rails'
gem 'selenium-webdriver'
gem 'shoulda-matchers'

View File

@ -1,93 +1,76 @@
GIT
remote: https://github.com/evolve75/RubyTree.git
revision: 6081d0959b706dcefb85e85faa329ebb2dabcf9e
specs:
rubytree (1.0.2)
json (~> 2.6.1)
structured_warnings (~> 0.4.0)
GIT
remote: https://github.com/gosukiwi/tubesock.git
revision: 86a5ca4f7d3c3a7b9a727ad91df3b9b4912eda39
branch: patch-1
specs:
tubesock (0.2.7)
rack (>= 1.5.0)
websocket (>= 1.1.0)
GIT
remote: https://github.com/openHPI/proforma.git
revision: cf61517a5cd765afb9d0d19ea1c692e18e3131d7
tag: v0.7.1
revision: 243853e66034bc2afbb9c9661475d9718d007304
branch: v0.5.2
specs:
proforma (0.7.1)
activemodel (>= 5.2.3, < 8.0.0)
activesupport (>= 5.2.3, < 8.0.0)
nokogiri (>= 1.10.2, < 2.0.0)
rubyzip (>= 1.2.2, < 3.0.0)
proforma (0.5.2)
activemodel (>= 5.2.3, < 7.2.0)
activesupport (>= 5.2.3, < 7.2.0)
nokogiri (~> 1.13)
rubyzip (~> 2.3)
GEM
remote: https://rubygems.org/
specs:
ZenTest (4.12.0)
actioncable (6.1.4.4)
actionpack (= 6.1.4.4)
activesupport (= 6.1.4.4)
ZenTest (4.12.1)
actioncable (6.1.6.1)
actionpack (= 6.1.6.1)
activesupport (= 6.1.6.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (6.1.4.4)
actionpack (= 6.1.4.4)
activejob (= 6.1.4.4)
activerecord (= 6.1.4.4)
activestorage (= 6.1.4.4)
activesupport (= 6.1.4.4)
actionmailbox (6.1.6.1)
actionpack (= 6.1.6.1)
activejob (= 6.1.6.1)
activerecord (= 6.1.6.1)
activestorage (= 6.1.6.1)
activesupport (= 6.1.6.1)
mail (>= 2.7.1)
actionmailer (6.1.4.4)
actionpack (= 6.1.4.4)
actionview (= 6.1.4.4)
activejob (= 6.1.4.4)
activesupport (= 6.1.4.4)
actionmailer (6.1.6.1)
actionpack (= 6.1.6.1)
actionview (= 6.1.6.1)
activejob (= 6.1.6.1)
activesupport (= 6.1.6.1)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
actionpack (6.1.4.4)
actionview (= 6.1.4.4)
activesupport (= 6.1.4.4)
actionpack (6.1.6.1)
actionview (= 6.1.6.1)
activesupport (= 6.1.6.1)
rack (~> 2.0, >= 2.0.9)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (6.1.4.4)
actionpack (= 6.1.4.4)
activerecord (= 6.1.4.4)
activestorage (= 6.1.4.4)
activesupport (= 6.1.4.4)
actiontext (6.1.6.1)
actionpack (= 6.1.6.1)
activerecord (= 6.1.6.1)
activestorage (= 6.1.6.1)
activesupport (= 6.1.6.1)
nokogiri (>= 1.8.5)
actionview (6.1.4.4)
activesupport (= 6.1.4.4)
actionview (6.1.6.1)
activesupport (= 6.1.6.1)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0)
activejob (6.1.4.4)
activesupport (= 6.1.4.4)
activejob (6.1.6.1)
activesupport (= 6.1.6.1)
globalid (>= 0.3.6)
activemodel (6.1.4.4)
activesupport (= 6.1.4.4)
activemodel (6.1.6.1)
activesupport (= 6.1.6.1)
activemodel-serializers-xml (1.0.2)
activemodel (> 5.x)
activesupport (> 5.x)
builder (~> 3.1)
activerecord (6.1.4.4)
activemodel (= 6.1.4.4)
activesupport (= 6.1.4.4)
activestorage (6.1.4.4)
actionpack (= 6.1.4.4)
activejob (= 6.1.4.4)
activerecord (= 6.1.4.4)
activesupport (= 6.1.4.4)
marcel (~> 1.0.0)
activerecord (6.1.6.1)
activemodel (= 6.1.6.1)
activesupport (= 6.1.6.1)
activestorage (6.1.6.1)
actionpack (= 6.1.6.1)
activejob (= 6.1.6.1)
activerecord (= 6.1.6.1)
activesupport (= 6.1.6.1)
marcel (~> 1.0)
mini_mime (>= 1.1.0)
activesupport (6.1.4.4)
activesupport (6.1.6.1)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
@ -101,7 +84,7 @@ GEM
minitest-autotest (~> 1.0)
autotest-rails (4.2.1)
ZenTest (~> 4.5)
bcrypt (3.1.16)
bcrypt (3.1.18)
better_errors (2.9.1)
coderay (>= 1.0.0)
erubi (>= 1.0.0)
@ -109,8 +92,8 @@ GEM
bindex (0.8.1)
binding_of_caller (1.0.0)
debug_inspector (>= 0.0.1)
bootsnap (1.9.3)
msgpack (~> 1.0)
bootsnap (1.13.0)
msgpack (~> 1.2)
bootstrap-will_paginate (1.0.0)
will_paginate
builder (3.2.4)
@ -118,7 +101,7 @@ GEM
amq-protocol (~> 2.3, >= 2.3.1)
sorted_set (~> 1, >= 1.0.2)
byebug (11.1.3)
capybara (3.36.0)
capybara (3.37.1)
addressable
matrix
mini_mime (>= 0.1.3)
@ -139,7 +122,7 @@ GEM
childprocess (4.1.0)
chronic (0.10.2)
coderay (1.1.3)
concurrent-ruby (1.1.9)
concurrent-ruby (1.1.10)
connection_pool (2.2.5)
crack (0.4.5)
rexml
@ -152,50 +135,36 @@ GEM
database_cleaner-core (2.0.1)
debug_inspector (1.1.0)
diff-lcs (1.5.0)
digest (3.1.0)
docile (1.4.0)
docker-api (2.2.0)
excon (>= 0.47.0)
multi_json
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
ecma-re-validator (0.3.0)
regexp_parser (~> 2.0)
erubi (1.10.0)
ecma-re-validator (0.4.0)
regexp_parser (~> 2.2)
erubi (1.11.0)
eventmachine (1.2.7)
excon (0.89.0)
factory_bot (6.2.0)
excon (0.92.4)
factory_bot (6.2.1)
activesupport (>= 5.0.0)
factory_bot_rails (6.2.0)
factory_bot (~> 6.2.0)
railties (>= 5.0.0)
faraday (1.9.3)
faraday-em_http (~> 1.0)
faraday-em_synchrony (~> 1.0)
faraday-excon (~> 1.1)
faraday-httpclient (~> 1.0)
faraday-multipart (~> 1.0)
faraday-net_http (~> 1.0)
faraday-net_http_persistent (~> 1.0)
faraday-patron (~> 1.0)
faraday-rack (~> 1.0)
faraday-retry (~> 1.0)
faraday (2.5.2)
faraday-net_http (>= 2.0, < 3.1)
ruby2_keywords (>= 0.0.4)
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
faraday-httpclient (1.0.1)
faraday-multipart (1.0.2)
multipart-post (>= 1.2, < 3)
faraday-net_http (1.0.1)
faraday-net_http_persistent (1.2.0)
faraday-patron (1.0.0)
faraday-rack (1.0.0)
faraday-retry (1.0.3)
faraday-net_http (3.0.0)
faraday-net_http_persistent (2.1.0)
faraday (~> 2.5)
net-http-persistent (~> 4.0)
faye-websocket (0.11.1)
eventmachine (>= 0.12.0)
websocket-driver (>= 0.5.1)
ffi (1.15.4)
ffi (1.15.5)
forgery (0.8.1)
glob (0.3.0)
globalid (1.0.0)
activesupport (>= 5.0)
haml (5.2.2)
@ -206,37 +175,38 @@ GEM
headless (2.3.1)
highline (2.0.3)
http-accept (1.7.0)
http-cookie (1.0.4)
http-cookie (1.0.5)
domain_name (~> 0.5)
i18n (1.8.11)
i18n (1.12.0)
concurrent-ruby (~> 1.0)
i18n-js (3.9.0)
i18n (>= 0.6.6)
image_processing (1.12.1)
i18n-js (4.0.0)
glob
i18n
image_processing (1.12.2)
mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3)
ims-lti (1.2.4)
ims-lti (1.2.6)
builder (>= 1.0, < 4.0)
oauth (>= 0.4.5, < 0.6)
influxdb (0.8.1)
jbuilder (2.11.5)
actionview (>= 5.0.0)
activesupport (>= 5.0.0)
jquery-rails (4.4.0)
jquery-rails (4.5.0)
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
jquery-ui-rails (6.0.1)
railties (>= 3.2.16)
js-routes (2.2.0)
js-routes (2.2.4)
railties (>= 4)
json (2.6.1)
json_schemer (0.2.18)
json (2.6.2)
json_schemer (0.2.21)
ecma-re-validator (~> 0.3)
hana (~> 1.3)
regexp_parser (~> 2.0)
uri_template (~> 0.7)
jwt (2.3.0)
jwt (2.4.1)
kaminari (1.2.2)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.2)
@ -249,12 +219,16 @@ GEM
activerecord
kaminari-core (= 1.2.2)
kaminari-core (1.2.2)
kramdown (2.3.1)
kramdown (2.4.0)
rexml
listen (3.7.0)
launchy (2.5.0)
addressable (~> 2.7)
letter_opener (1.8.1)
launchy (>= 2.2, < 3)
listen (3.7.1)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
loofah (2.13.0)
loofah (2.18.0)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
mail (2.7.1)
@ -270,85 +244,97 @@ GEM
rake
mini_magick (4.11.0)
mini_mime (1.1.2)
mini_portile2 (2.5.3)
minitest (5.15.0)
mini_portile2 (2.8.0)
minitest (5.16.3)
minitest-autotest (1.1.1)
minitest-server (~> 1.0)
path_expander (~> 1.0)
minitest-server (1.0.6)
minitest (~> 5.0)
mnemosyne-ruby (1.12.0)
mnemosyne-ruby (1.13.0)
activesupport (>= 4)
bunny
msgpack (1.4.2)
msgpack (1.5.4)
multi_json (1.15.0)
multi_xml (0.6.0)
multipart-post (2.1.1)
nested_form (0.3.2)
net-http-persistent (4.0.1)
connection_pool (~> 2.2)
net-imap (0.2.3)
digest
net-protocol
strscan
net-pop (0.1.1)
digest
net-protocol
timeout
net-protocol (0.1.3)
timeout
net-smtp (0.3.1)
digest
net-protocol
timeout
netrc (0.11.0)
newrelic_rpm (8.2.0)
nio4r (2.5.8)
nokogiri (1.11.7)
mini_portile2 (~> 2.5.0)
nokogiri (1.13.8)
mini_portile2 (~> 2.8.0)
racc (~> 1.4)
nyan-cat-formatter (0.12.0)
rspec (>= 2.99, >= 2.14.2, < 4)
oauth (0.5.8)
oauth2 (1.4.7)
faraday (>= 0.8, < 2.0)
oauth (0.5.10)
oauth2 (1.4.10)
faraday (>= 0.17.3, < 3.0)
jwt (>= 1.0, < 3.0)
multi_json (~> 1.3)
multi_xml (~> 0.5)
rack (>= 1.2, < 3)
pagedown-bootstrap-rails (2.1.4)
railties (> 3.1)
parallel (1.21.0)
parser (3.1.0.0)
parallel (1.22.1)
parser (3.1.2.1)
ast (~> 2.4.1)
path_expander (1.1.0)
pg (1.2.3)
prometheus_exporter (1.0.1)
path_expander (1.1.1)
pg (1.4.3)
prometheus_exporter (2.0.3)
webrick
pry (0.13.1)
pry (0.14.1)
coderay (~> 1.1)
method_source (~> 1.0)
pry-byebug (3.9.0)
pry-byebug (3.10.1)
byebug (~> 11.0)
pry (~> 0.13.0)
pry (>= 0.13, < 0.15)
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (4.0.6)
puma (5.5.2)
public_suffix (4.0.7)
puma (5.6.4)
nio4r (~> 2.0)
pundit (2.1.1)
pundit (2.2.0)
activesupport (>= 3.0.0)
racc (1.6.0)
rack (2.2.3)
rack-mini-profiler (2.3.3)
rack (2.2.4)
rack-mini-profiler (3.0.0)
rack (>= 1.2.0)
rack-pjax (1.1.0)
nokogiri (~> 1.5)
rack (>= 1.1)
rack-proxy (0.7.2)
rack
rack-test (1.1.0)
rack (>= 1.0, < 3)
rails (6.1.4.4)
actioncable (= 6.1.4.4)
actionmailbox (= 6.1.4.4)
actionmailer (= 6.1.4.4)
actionpack (= 6.1.4.4)
actiontext (= 6.1.4.4)
actionview (= 6.1.4.4)
activejob (= 6.1.4.4)
activemodel (= 6.1.4.4)
activerecord (= 6.1.4.4)
activestorage (= 6.1.4.4)
activesupport (= 6.1.4.4)
rack-test (2.0.2)
rack (>= 1.3)
rails (6.1.6.1)
actioncable (= 6.1.6.1)
actionmailbox (= 6.1.6.1)
actionmailer (= 6.1.6.1)
actionpack (= 6.1.6.1)
actiontext (= 6.1.6.1)
actionview (= 6.1.6.1)
activejob (= 6.1.6.1)
activemodel (= 6.1.6.1)
activerecord (= 6.1.6.1)
activestorage (= 6.1.6.1)
activesupport (= 6.1.6.1)
bundler (>= 1.15.0)
railties (= 6.1.4.4)
railties (= 6.1.6.1)
sprockets-rails (>= 2.0.0)
rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1)
@ -357,14 +343,14 @@ GEM
rails-dom-testing (2.0.3)
activesupport (>= 4.2.0)
nokogiri (>= 1.6)
rails-html-sanitizer (1.4.2)
rails-html-sanitizer (1.4.3)
loofah (~> 2.3)
rails-i18n (7.0.1)
rails-i18n (7.0.5)
i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8)
rails-timeago (2.19.1)
actionpack (>= 3.1)
activesupport (>= 3.1)
rails-timeago (2.20.0)
actionpack (>= 5.2)
activesupport (>= 5.2)
rails_admin (2.2.1)
activemodel-serializers-xml (>= 1.0)
builder (~> 3.1)
@ -377,23 +363,23 @@ GEM
rails (>= 5.0, < 7)
remotipart (~> 1.3)
sassc-rails (>= 1.3, < 3)
railties (6.1.4.4)
actionpack (= 6.1.4.4)
activesupport (= 6.1.4.4)
railties (6.1.6.1)
actionpack (= 6.1.6.1)
activesupport (= 6.1.6.1)
method_source
rake (>= 0.13)
rake (>= 12.2)
thor (~> 1.0)
rainbow (3.0.0)
rainbow (3.1.1)
rake (13.0.6)
ransack (2.5.0)
activerecord (>= 5.2.4)
activesupport (>= 5.2.4)
ransack (3.2.1)
activerecord (>= 6.1.5)
activesupport (>= 6.1.5)
i18n
rb-fsevent (0.11.0)
rb-fsevent (0.11.1)
rb-inotify (0.10.1)
ffi (~> 1.0)
rbtree (0.4.4)
regexp_parser (2.2.0)
rbtree (0.4.5)
regexp_parser (2.5.0)
remotipart (1.4.4)
rest-client (2.1.0)
http-accept (>= 1.7.0, < 2.0)
@ -401,23 +387,25 @@ GEM
mime-types (>= 1.16, < 4.0)
netrc (~> 0.8)
rexml (3.2.5)
rspec (3.10.0)
rspec-core (~> 3.10.0)
rspec-expectations (~> 3.10.0)
rspec-mocks (~> 3.10.0)
rspec (3.11.0)
rspec-core (~> 3.11.0)
rspec-expectations (~> 3.11.0)
rspec-mocks (~> 3.11.0)
rspec-autotest (1.0.2)
rspec-core (>= 2.99.0.beta1, < 4.0.0)
rspec-collection_matchers (1.2.0)
rspec-expectations (>= 2.99.0.beta1)
rspec-core (3.10.1)
rspec-support (~> 3.10.0)
rspec-expectations (3.10.1)
rspec-core (3.11.0)
rspec-support (~> 3.11.0)
rspec-expectations (3.11.0)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.10.0)
rspec-mocks (3.10.2)
rspec-support (~> 3.11.0)
rspec-github (2.3.1)
rspec-core (~> 3.0)
rspec-mocks (3.11.1)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.10.0)
rspec-rails (5.0.2)
rspec-support (~> 3.11.0)
rspec-rails (5.1.2)
actionpack (>= 5.2)
activesupport (>= 5.2)
railties (>= 5.2)
@ -425,31 +413,34 @@ GEM
rspec-expectations (~> 3.10)
rspec-mocks (~> 3.10)
rspec-support (~> 3.10)
rspec-support (3.10.3)
rubocop (1.24.1)
rspec-support (3.11.0)
rubocop (1.35.0)
json (~> 2.3)
parallel (~> 1.10)
parser (>= 3.0.0.0)
parser (>= 3.1.2.1)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml
rubocop-ast (>= 1.15.1, < 2.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.20.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 3.0)
rubocop-ast (1.15.1)
parser (>= 3.0.1.1)
rubocop-performance (1.13.1)
rubocop-ast (1.21.0)
parser (>= 3.1.1.0)
rubocop-performance (1.14.3)
rubocop (>= 1.7.0, < 2.0)
rubocop-ast (>= 0.4.0)
rubocop-rails (2.13.1)
rubocop-rails (2.15.2)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 1.7.0, < 2.0)
rubocop-rspec (2.7.0)
rubocop (~> 1.19)
rubocop-rspec (2.12.1)
rubocop (~> 1.31)
ruby-progressbar (1.11.0)
ruby-vips (2.1.4)
ffi (~> 1.12)
ruby2_keywords (0.0.5)
rubytree (2.0.0)
json (~> 2.0, > 2.3.1)
rubyzip (2.3.2)
sass-rails (6.0.0)
sassc-rails (~> 2.1, >= 2.1.1)
@ -461,22 +452,23 @@ GEM
sprockets (> 3.0)
sprockets-rails
tilt
selenium-webdriver (4.1.0)
selenium-webdriver (4.4.0)
childprocess (>= 0.5, < 5.0)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
semantic_range (3.0.0)
sentry-rails (4.8.3)
sentry-rails (5.4.2)
railties (>= 5.0)
sentry-ruby-core (~> 4.8.3)
sentry-ruby (4.8.3)
sentry-ruby (~> 5.4.2)
sentry-ruby (5.4.2)
concurrent-ruby (~> 1.0, >= 1.0.2)
faraday (~> 1.0)
sentry-ruby-core (= 4.8.3)
sentry-ruby-core (4.8.3)
concurrent-ruby
faraday
set (1.0.2)
shakapacker (6.5.1)
activesupport (>= 5.2)
rack-proxy (>= 0.6.1)
railties (>= 5.2)
semantic_range (>= 2.3.0)
shoulda-matchers (5.1.0)
activesupport (>= 5.2.0)
simplecov (0.21.2)
@ -484,15 +476,15 @@ GEM
simplecov-html (~> 0.11)
simplecov_json_formatter (~> 0.1)
simplecov-html (0.12.3)
simplecov_json_formatter (0.1.3)
simplecov_json_formatter (0.1.4)
slim (4.1.0)
temple (>= 0.7.6, < 0.9)
tilt (>= 2.0.6, < 2.1)
slim-rails (3.3.0)
slim-rails (3.5.1)
actionpack (>= 3.1)
railties (>= 3.1)
slim (>= 3.0, < 5.0)
sorcery (0.16.2)
sorcery (0.16.3)
bcrypt (~> 3.1)
oauth (~> 0.5, >= 0.5.5)
oauth2 (~> 1.0, >= 0.8.0)
@ -500,44 +492,43 @@ GEM
rbtree
set (~> 1.0)
spring (4.0.0)
sprockets (4.0.2)
sprockets (4.1.1)
concurrent-ruby (~> 1.0)
rack (> 1, < 3)
sprockets-rails (3.4.2)
actionpack (>= 5.2)
activesupport (>= 5.2)
sprockets (>= 3.0.0)
ssrf_filter (1.0.7)
structured_warnings (0.4.0)
telegraf (2.0.0)
ssrf_filter (1.0.8)
strscan (3.0.4)
telegraf (2.1.0)
influxdb
temple (0.8.2)
thor (1.2.1)
tilt (2.0.10)
tilt (2.0.11)
timeout (0.3.0)
tubesock (0.2.9)
rack (>= 1.5.0)
websocket (>= 1.1.0)
turbolinks (5.2.1)
turbolinks-source (~> 5.2)
turbolinks-source (5.2.0)
tzinfo (2.0.4)
tzinfo (2.0.5)
concurrent-ruby (~> 1.0)
unf (0.1.4)
unf_ext
unf_ext (0.0.8)
unicode-display_width (2.1.0)
unf_ext (0.0.8.2)
unicode-display_width (2.2.0)
uri_template (0.7.0)
web-console (4.2.0)
actionview (>= 6.0.0)
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
webmock (3.14.0)
webmock (3.18.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webpacker (5.4.3)
activesupport (>= 5.2)
rack-proxy (>= 0.6.1)
railties (>= 5.2)
semantic_range (>= 2.3.0)
webrick (1.7.0)
websocket (1.2.9)
websocket-driver (0.7.5)
@ -548,7 +539,7 @@ GEM
will_paginate (3.3.1)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.5.3)
zeitwerk (2.6.0)
PLATFORMS
ruby
@ -569,6 +560,7 @@ DEPENDENCIES
eventmachine
factory_bot_rails
faraday
faraday-net_http_persistent
faye-websocket
forgery
headless
@ -579,11 +571,14 @@ DEPENDENCIES
js-routes
json_schemer
kramdown
letter_opener
listen
mimemagic
mnemosyne-ruby
net-http-persistent
newrelic_rpm
net-imap
net-pop
net-smtp
nokogiri
nyan-cat-formatter
pagedown-bootstrap-rails
@ -595,38 +590,39 @@ DEPENDENCIES
puma
pundit
rack-mini-profiler
rails (~> 6.1.4)
rails (~> 6.1.6)
rails-controller-testing
rails-i18n
rails-timeago
rails_admin
rails_admin (< 3.0.0)
ransack
rest-client
rspec-autotest
rspec-collection_matchers
rspec-github
rspec-rails
rubocop
rubocop-performance
rubocop-rails
rubocop-rspec
rubytree!
rubytree
rubyzip
sass-rails
selenium-webdriver
sentry-rails
sentry-ruby
shakapacker (= 6.5.1)
shoulda-matchers
simplecov
slim-rails
sorcery
spring
telegraf
tubesock!
tubesock
turbolinks
web-console
webmock
webpacker
whenever
BUNDLED WITH
2.3.4
2.3.17

View File

@ -18,7 +18,7 @@ The programming courses offered on openHPI include practical programming exercis
3. CodeOcean includes unit tests to provide feedback for learners and score their code. A unit test is defined as a program that either runs the learners code in a pre-defined way and compares the provided result with an expectation or the unit test parses the students source code and matches it against an exercise-defined string. While the code of the unit tests are hidden, learners can run the unit tests at any time and get instant feedback whether they passed or failed. If the unit tests fail the result is shown together with an error message defined by the MOOC instructors. On the one hand, this feedback helps people to help themselves and provides learners with a hint of their mistake. On the other hand, the automated scoring using unit tests is required to indicate progress for the learners. In the context of a MOOC with thousands of active learners, a manual review by the instructors is not feasible and peer-review of source code has not been implemented in CodeOcean so far.
4. In CodeOcean, learners can ask questions about their program directly within the platform and in context of their current program. Usually, MOOC platforms provide a forum to discuss questions. While this concept also works great for source code in general outside of a MOOC (cf. [StackOverflow](https://stackoverflow.com)), it is an additional barrier for novices to summarize their problem externally. To understand the problem, contextual information is generally of help for others to provide the current solution. When using a dedicated forum, learners are required to provide as much information as necessary to reproduce the issue which beginners might find difficult to identify. As a result, they might copy too few or too much information. In addition, early iterations of the Java courses showed that learners did not format their source code appropriate in forum posts (but as plain text), making it difficult to read. With _Request for Comments_, CodeOcean provides a built-in feature to ask a question in the context of an exercise, thus lowering the barriers to get help. CodeOcean presents the learners source code and error message together with the question to fellow students and allows them to add a comment specifically to one line of code. Hence, the previously described issue is solved with a dedicated forum.
CodeOcean is mainly used in the context of MOOCs (such as those offered on openHPI and mooc.house) and has been used by more than 60,000 users as of June 2020. CodeOcean is a stand-alone tool implementing the [Learning Tools Interoperability (LTI)](http://www.imsglobal.org/activity/learning-tools-interoperability) standard to be used in various learning scenarios. By offering an LTI interface, it is accessible from MOOC providers as well as other providers, such as the HPI Schul-Cloud. CodeOcean itself cannot be used directly by learners or other users than the MOOCs instructors or administrators.
CodeOcean is mainly used in the context of MOOCs (such as those offered on openHPI and mooc.house) and has been used by more than 60,000 users as of June 2020. CodeOcean is a stand-alone tool implementing the [Learning Tools Interoperability (LTI)](https://www.imsglobal.org/activity/learning-tools-interoperability) standard to be used in various learning scenarios. By offering an LTI interface, it is accessible from MOOC providers as well as other providers, such as the HPI Schul-Cloud. CodeOcean itself cannot be used directly by learners or other users than the MOOCs instructors or administrators.
## Development Setup
@ -48,7 +48,7 @@ In order to execute code submissions using the [DockerContainerPool](https://git
## Production Setup
- We recommend using [Capistrano](http://capistranorb.com/) for deployment.
- We recommend using [Capistrano](https://capistranorb.com/) for deployment.
- Once deployed, CodeOcean assumes to run exclusively under a (sub)domain. If you want to use it under a custom subpath, you can specify the desired path using an environment variable: `RAILS_RELATIVE_URL_ROOT=/codeocean`. Please ensure to rebuild all assets and restart the server to apply the new path.
## Monitoring

2
Vagrantfile vendored
View File

@ -10,7 +10,7 @@ Vagrant.configure(2) do |config|
v.cpus = 4
end
config.vm.network 'forwarded_port',
host_ip: ENV['LISTEN_ADDRESS'] || '127.0.0.1',
host_ip: ENV.fetch('LISTEN_ADDRESS', '127.0.0.1'),
host: 7000,
guest: 7000
config.vm.synced_folder '.', '/home/vagrant/codeocean'

View File

@ -14,12 +14,9 @@
//= require pagedown_bootstrap
//= require rails-timeago
//= require locales/jquery.timeago.de.js
//= require i18n
//= require i18n/translations
//
// lib/assets
//= require flash
//= require url
//
// vendor/assets
//= require ace/ace

View File

@ -1,6 +1,6 @@
$(document).on('turbolinks:load', function() {
var subMenusSelector = 'ul.dropdown-menu [data-toggle=dropdown]';
var subMenusSelector = 'ul.dropdown-menu [data-bs-toggle=dropdown]';
function openSubMenu(event) {
if (this.pathname === '/') {

View File

@ -1,5 +1,5 @@
$(document).on('turbolinks:load', function() {
$('[data-toggle="tooltip"]').tooltip();
$('[data-bs-toggle="tooltip"]').tooltip();
if($.isController('codeharbor_links')) {
if ($('.edit_codeharbor_link, .new_codeharbor_link').isPresent()) {

View File

@ -12,7 +12,7 @@ $(document).on('turbolinks:load', function() {
return _.map($('tbody tr[data-id]'), function(element) {
return {
content: $('td.name', element).text(),
id: $(element).data('id'),
id: `execution_environment_${$(element).data('id')}`,
visible: false
};
});
@ -67,7 +67,7 @@ $(document).on('turbolinks:load', function() {
var setGroupVisibility = function(response) {
_.each(response.docker, function(data) {
groups.update({
id: data.id,
id: `execution_environment_${data.id}`,
visible: data.prewarmingPoolSize > 0
});
});
@ -76,7 +76,7 @@ $(document).on('turbolinks:load', function() {
var updateChartData = function(response) {
_.each(response.docker, function(data) {
dataset.add({
group: data.id,
group: `execution_environment_${data.id}`,
x: vis.moment(),
y: data.usedRunners
});

View File

@ -15,12 +15,9 @@ $(document).on('turbolinks:load', function(event) {
);
if ($('#editor').isPresent() && CodeOceanEditor && event.originalEvent.data.url.includes("/implement")) {
if (CodeOceanEditor.isBrowserSupported()) {
$('#alert').hide();
// This call will (amon other things) initializeEditors and load the content except for the last line
// It must not be called during page navigation. Otherwise, content will be duplicated!
// Search for insertLines and Turbolinks reload / cache control
CodeOceanEditor.initializeEverything();
}
}
});

View File

@ -17,7 +17,7 @@ var CodeOceanEditor = {
//Request-For-Comments-Configuration
REQUEST_FOR_COMMENTS_DELAY: 0,
REQUEST_TOOLTIP_TIME: 5000,
REQUEST_TOOLTIP_DELAY: 10 * 60 * 1000,
REQUEST_TOOLTIP_DELAY: 15 * 60 * 1000,
editors: [],
editor_for_file: new Map(),
@ -78,7 +78,7 @@ var CodeOceanEditor = {
if ($('#output-' + index).isPresent()) {
return $('#output-' + index);
} else {
var element = $('<pre class="mt-2">').attr('id', 'output-' + index);
var element = $('<pre class="mb-2">').attr('id', 'output-' + index);
$('#output').append(element);
return element;
}
@ -216,8 +216,8 @@ var CodeOceanEditor = {
},
hideSpinner: function () {
$('button i.fa, button i.far, button i.fas').show();
$('button i.fa-spin').hide();
$('button i.fa-solid, button i.fa-regular').show();
$('button i.fa-spin').removeClass('d-inline-block').addClass('d-none');
},
@ -235,10 +235,20 @@ var CodeOceanEditor = {
window.dispatchEvent(new Event('resize'));
},
resizeParentOfAceEditor: function (element) {
resizeSidebars: function () {
$('#content-left-sidebar').height(this.calculateEditorHeight('#content-left-sidebar', false));
$('#content-right-sidebar').height(this.calculateEditorHeight('#content-right-sidebar', false));
},
calculateEditorHeight: function (element, considerStatusbar) {
let bottom = considerStatusbar ? ($('#statusbar').height() || 0) : 0;
// calculate needed size: window height - position of top of ACE editor - height of autosave label below editor - 5 for bar margins
var windowHeight = window.innerHeight - $(element).offset().top - ($('#statusbar').height() || 0) - 5;
$(element).parent().height(windowHeight);
return window.innerHeight - $(element).offset().top - bottom - 5;
},
resizeParentOfAceEditor: function (element) {
const editorHeight = this.calculateEditorHeight(element, true);
$(element).parent().height(editorHeight);
},
initializeEditors: function (own_solution = false) {
@ -259,6 +269,7 @@ var CodeOceanEditor = {
// Resize frame on window size change
$(window).resize(function () {
this.resizeParentOfAceEditor(element);
this.resizeSidebars();
}.bind(this));
var editor = ace.edit(element);
@ -366,11 +377,9 @@ var CodeOceanEditor = {
}
filesInstance.jstree(filesInstance.data('entries'));
filesInstance.on('click', 'li.jstree-leaf > a', function (event) {
this.setActiveFile(
$(event.target).parent().text(),
parseInt($(event.target).parent().attr('id'))
);
var frame = $('[data-file-id="' + this.active_file.id + '"]').parent();
const file_id = parseInt($(event.target).parent().attr('id'));
const frame = $('[data-file-id="' + file_id + '"]').parent();
this.setActiveFile(frame.data('filename'), file_id);
this.showFrame(frame);
this.toggleButtonStates();
}.bind(this));
@ -392,6 +401,7 @@ var CodeOceanEditor = {
tipButton.on('click', this.handleSideBarToggle.bind(this));
}
$('#sidebar').on('transitionend', this.resizeAceEditors.bind(this));
$('#sidebar').on('transitionend', this.resizeSidebars.bind(this));
},
handleSideBarToggle: function () {
@ -435,12 +445,12 @@ var CodeOceanEditor = {
button.prop('disabled', true);
button.on('click', function () {
$('#rfc_intervention_text').hide()
$('#comment-modal').modal('show');
new bootstrap.Modal($('#comment-modal')).show();
});
$('#askForCommentsButton').on('click', this.requestComments.bind(this));
$('#closeAskForCommentsButton').on('click', function () {
$('#comment-modal').modal('hide');
bootstrap.Modal.getInstance($('#comment-modal')).hide();
});
setTimeout(function () {
@ -477,30 +487,24 @@ var CodeOceanEditor = {
return this.isActiveFileExecutable() && ['teacher_defined_test', 'user_defined_test', 'teacher_defined_linter'].includes(this.active_frame.data('role'));
},
isBrowserSupported: function () {
// websockets are used for run, score and test
// Also exclude IE and IE 11
return Modernizr.websockets && window.navigator.userAgent.indexOf("MSIE") <= 0 && !navigator.userAgent.match(/Trident\/7\./);
},
populateCard: function (card, result, index) {
card.addClass(this.getCardClass(result));
card.find('.card-title .filename').text(result.filename);
card.find('.card-title .number').text(index + 1);
card.find('.row .col-sm-9').eq(0).find('.number').eq(0).text(result.passed);
card.find('.row .col-sm-9').eq(0).find('.number').eq(1).text(result.count);
card.find('.row .col-md-9').eq(0).find('.number').eq(0).text(result.passed);
card.find('.row .col-md-9').eq(0).find('.number').eq(1).text(result.count);
if (result.weight !== 0) {
card.find('.row .col-sm-9').eq(1).find('.number').eq(0).text(parseFloat((result.score * result.weight).toFixed(2)));
card.find('.row .col-sm-9').eq(1).find('.number').eq(1).text(result.weight);
card.find('.row .col-md-9').eq(1).find('.number').eq(0).text(parseFloat((result.score * result.weight).toFixed(2)));
card.find('.row .col-md-9').eq(1).find('.number').eq(1).text(result.weight);
} else {
// Hide score row if no score could be achieved
card.find('.attribute-row.row').eq(1).addClass('d-none');
}
card.find('.row .col-sm-9').eq(2).html(result.message);
card.find('.row .col-md-9').eq(2).html(result.message);
// Add error message from code to card
if (result.error_messages) {
const targetNode = card.find('.row .col-sm-9').eq(3);
const targetNode = card.find('.row .col-md-9').eq(3);
let errorMessagesToShow = [];
result.error_messages.forEach(function (item) {
@ -571,7 +575,7 @@ var CodeOceanEditor = {
}
targetNode.append(ul);
}
//card.find('.row .col-sm-9').eq(4).find('a').attr('href', '#output-' + index);
//card.find('.row .col-md-9').eq(4).find('a').attr('href', '#output-' + index);
},
createEventHandler: function (eventType, data) {
@ -652,12 +656,17 @@ var CodeOceanEditor = {
let matches;
let augmented_text = text;
// Switch both lines below to enable the output of images and render <IMG/> tags.
// Also consider `printOutput` in evaluation.js
// let augmented_text = element.text();
let augmented_text = element.html();
while (matches = this.tracepositions_regex.exec(text)) {
const frame = $('div.frame[data-filename="' + matches[1] + '"]')
if (frame.length > 0) {
augmented_text = augmented_text.replace(new RegExp(matches[0], 'g'), "<a href='#' data-file='" + matches[1] + "' data-line='" + matches[2] + "'>" + matches[0] + "</a>");
// augmented_text = augmented_text.replace(new RegExp(matches[0], 'g'), "<a href='#' data-file='" + matches[1] + "' data-line='" + matches[2] + "'>" + matches[0] + "</a>");
augmented_text = augmented_text.replace(new RegExp(_.unescape(matches[0]), 'g'), "<a href='#' data-file='" + matches[1] + "' data-line='" + matches[2] + "'>" + matches[0] + "</a>");
}
}
element.html(augmented_text);
@ -694,8 +703,8 @@ var CodeOceanEditor = {
},
showSpinner: function (initiator) {
$(initiator).find('i.fa, i.far, i.fas').hide();
$(initiator).find('i.fa-spin').show();
$(initiator).find('i.fa-solid, i.fa-regular').hide();
$(initiator).find('i.fa-spin').addClass('d-inline-block').removeClass('d-none');
},
showStatus: function (output) {
@ -703,9 +712,11 @@ var CodeOceanEditor = {
this.showTimeoutMessage();
} else if (output.status === 'container_depleted') {
this.showContainerDepletedMessage();
} else if (output.status === 'out_of_memory') {
this.showOutOfMemoryMessage();
} else if (output.stderr) {
$.flash.danger({
icon: ['fa', 'fa-bug'],
icon: ['fa-solid', 'fa-bug'],
text: $('#run').data('message-failure')
});
Sentry.captureException(JSON.stringify(output));
@ -738,14 +749,21 @@ var CodeOceanEditor = {
showContainerDepletedMessage: function () {
$.flash.danger({
icon: ['fa', 'fa-clock-o'],
icon: ['fa-regular', 'fa-clock'],
text: $('#editor').data('message-depleted')
});
},
showOutOfMemoryMessage: function () {
$.flash.info({
icon: ['fa-regular', 'fa-clock'],
text: $('#editor').data('message-out-of-memory')
});
},
showTimeoutMessage: function () {
$.flash.info({
icon: ['fa', 'fa-clock-o'],
icon: ['fa-regular', 'fa-clock'],
text: $('#editor').data('message-timeout')
});
},
@ -766,7 +784,7 @@ var CodeOceanEditor = {
event.preventDefault();
this.createSubmission('#create-file', null, function (response) {
$('#code_ocean_file_context_id').val(response.id);
$('#modal-file').modal('show');
new bootstrap.Modal($('#modal-file')).show();
}.bind(this));
},
@ -774,6 +792,7 @@ var CodeOceanEditor = {
$('#toggle-sidebar-output').on('click', this.hideOutputBar.bind(this));
$('#toggle-sidebar-output-collapsed').on('click', this.showOutputBar.bind(this));
$('#output_sidebar').on('transitionend', this.resizeAceEditors.bind(this));
$('#output_sidebar').on('transitionend', this.resizeSidebars.bind(this));
},
showOutputBar: function () {
@ -789,7 +808,7 @@ var CodeOceanEditor = {
},
initializeSideBarTooltips: function () {
$('[data-toggle="tooltip"]').tooltip()
$('[data-bs-toggle="tooltip"]').tooltip()
},
initializeDescriptionToggle: function () {
@ -797,12 +816,13 @@ var CodeOceanEditor = {
$('a#toggle').on('click', this.toggleDescriptionCard.bind(this));
},
toggleDescriptionCard: function () {
toggleDescriptionCard: function (event) {
$('#description-card').toggleClass('description-card-collapsed').toggleClass('description-card');
$('#description-symbol').toggleClass('fa-chevron-down').toggleClass('fa-chevron-right');
var toggle = $('a#toggle');
toggle.text(toggle.text() == toggle.data('hide') ? toggle.data('show') : toggle.data('hide'));
this.resizeAceEditors();
this.resizeSidebars();
event.preventDefault();
},
@ -835,11 +855,7 @@ var CodeOceanEditor = {
const percentile75 = data['working_time_75_percentile'];
const accumulatedWorkTimeUser = data['working_time_accumulated'];
let minTimeIntervention = 10 * 60 * 1000;
if ($('#editor').data('exercise-id') === 909) {
// 30 minutes for our large Map exercise
minTimeIntervention = 30 * 60 * 1000;
}
let minTimeIntervention = 20 * 60 * 1000;
let timeUntilIntervention;
if ((accumulatedWorkTimeUser - percentile75) > 0) {
@ -861,17 +877,21 @@ var CodeOceanEditor = {
clearInterval(tid);
// timeUntilIntervention passed
if (editor.data('tips-interventions')) {
$('#tips-intervention-modal').modal('show');
const modal = $('#tips-intervention-modal');
modal.find('.modal-footer').html(I18n.t("exercises.implement.intervention.explanation", {duration: Math.round(percentile75 / 60)}));
new bootstrap.Modal(modal).show();
$.ajax({
data: {
intervention_type: 'TipIntervention'
intervention_type: 'TipsIntervention'
},
dataType: 'json',
type: 'POST',
url: interventionSaveUrl
});
} else if (editor.data('break-interventions')) {
$('#break-intervention-modal').modal('show');
const modal = $('#break-intervention-modal');
modal.find('.modal-footer').html(I18n.t("exercises.implement.intervention.explanation", {duration: Math.round(percentile75 / 60)}));
new bootstrap.Modal(modal).show();
$.ajax({
data: {
intervention_type: 'BreakIntervention'
@ -885,7 +905,12 @@ var CodeOceanEditor = {
// only show intervention if user did not requested for a comment already
if (!button.prop('disabled')) {
$('#rfc_intervention_text').show();
$('#comment-modal').modal('show');
modal = $('#comment-modal');
modal.find('.modal-footer').html(I18n.t("exercises.implement.intervention.explanation", {duration: Math.round(percentile75 / 60)}));
modal.on('hidden.bs.modal', function () {
modal.find('.modal-footer').text('');
});
new bootstrap.Modal(modal).show();
$.ajax({
data: {
intervention_type: 'QuestionIntervention'
@ -928,7 +953,6 @@ var CodeOceanEditor = {
CodeOceanEditor.editors = [];
this.initializeRegexes();
this.initializeCodePilot();
$('.score, #development-environment').show();
this.configureEditors();
this.initializeEditors();
this.initializeEventHandlers();
@ -944,6 +968,7 @@ var CodeOceanEditor = {
this.renderScore();
this.showFirstFile();
this.resizeAceEditors();
this.resizeSidebars();
this.initializeDeadlines();
CodeOceanEditorTips.initializeEventHandlers();

View File

@ -1,5 +1,8 @@
CodeOceanEditorEvaluation = {
chunkBuffer: [{streamedResponse: true}],
// A list of non-printable characters that are not allowed in the code output.
// Taken from https://stackoverflow.com/a/69024306
nonPrintableRegEx: /[\u0000-\u0008\u000B\u000C\u000F-\u001F\u007F-\u009F\u2000-\u200F\u2028-\u202F\u205F-\u206F\u3000\uFEFF]/g,
/**
* Scoring-Functions
@ -99,6 +102,11 @@ CodeOceanEditorEvaluation = {
})) {
this.showTimeoutMessage();
}
if (_.some(response, function (result) {
return result.status === 'out_of_memory';
})) {
this.showOutOfMemoryMessage();
}
if (_.some(response, function (result) {
return result.status === 'container_depleted';
})) {
@ -199,26 +207,39 @@ CodeOceanEditorEvaluation = {
return;
}
if (output.stdout !== undefined && !output.stdout.startsWith("<img")) {
output.stdout = _.escape(output.stdout);
}
var element = this.findOrCreateOutputElement(index);
// Switch all four lines below to enable the output of images and render <IMG/> tags
// Switch all four lines below to enable the output of images and render <IMG/> tags.
// Also consider `augmentStacktraceInOutput` in editor.js.erb
if (!colorize) {
if (output.stdout !== undefined && output.stdout !== '') {
//element.append(output.stdout)
element.text(element.text() + output.stdout)
output.stdout = output.stdout.replace(this.nonPrintableRegEx, "")
element.append(output.stdout)
//element.text(element.text() + output.stdout)
}
if (output.stderr !== undefined && output.stderr !== '') {
//element.append('StdErr: ' + output.stderr);
element.text('StdErr: ' + element.text() + output.stderr);
output.stderr = output.stderr.replace(this.nonPrintableRegEx, "")
element.append('StdErr: ' + output.stderr);
//element.text('StdErr: ' + element.text() + output.stderr);
}
} else if (output.stderr) {
//element.addClass('text-warning').append(output.stderr);
element.addClass('text-warning').text(element.text() + output.stderr);
output.stderr = output.stderr.replace(this.nonPrintableRegEx, "")
element.addClass('text-warning').append(output.stderr);
//element.addClass('text-warning').text(element.text() + output.stderr);
this.QaApiOutputBuffer.stderr += output.stderr;
} else if (output.stdout) {
//element.addClass('text-success').append(output.stdout);
element.addClass('text-success').text(element.text() + output.stdout);
output.stdout = output.stdout.replace(this.nonPrintableRegEx, "")
element.addClass('text-success').append(output.stdout);
//element.addClass('text-success').text(element.text() + output.stdout);
this.QaApiOutputBuffer.stdout += output.stdout;
} else {
element.addClass('text-muted').text($('#output').data('message-no-output'));

View File

@ -46,7 +46,6 @@ CodeOceanEditorWebsocket = {
this.websocket.on('turtlebatch', this.handleTurtlebatchCommand.bind(this));
this.websocket.on('render', this.renderWebsocketOutput.bind(this));
this.websocket.on('exit', this.handleExitCommand.bind(this));
this.websocket.on('timeout', this.showTimeoutMessage.bind(this));
this.websocket.on('status', this.showStatus.bind(this));
this.websocket.on('hint', this.showHint.bind(this));
},

View File

@ -4,9 +4,9 @@ CodeOceanEditorFlowr = {
'<div class="card mb-2">' +
'<div id="{{headingId}}" role="tab" class="card-header">' +
'<div class="card-title mb-0">' +
'<a class="collapsed" data-toggle="collapse" data-parent="#flowrHint" href="#{{collapseId}}" aria-expanded="false" aria-controls="{{collapseId}}">' +
'<a class="collapsed" data-bs-toggle="collapse" data-bs-parent="#flowrHint" href="#{{collapseId}}" aria-expanded="false" aria-controls="{{collapseId}}">' +
'<div class="clearfix" role="button">' +
'<i class="fa" aria-hidden="true"></i>' +
'<i class="fa-solid" aria-hidden="true"></i>' +
'<span>' +
'</span>' +
'</div>' +
@ -14,7 +14,7 @@ CodeOceanEditorFlowr = {
'</div>' +
'</div>' +
'<div id="{{collapseId}}" role="tabpanel" aria-labelledby="{{headingId}}" class="card card-collapse collapse">' +
'<div class="card-body"></div>' +
'<div class="card-body d-grid gap-2"></div>' +
'</div>' +
'</div>',
@ -93,7 +93,7 @@ CodeOceanEditorFlowr = {
var body = resultTile.find('.card-body');
body.html(result.body);
body.append('<a target="_blank" href="' + questionUrl + '" class="btn btn-primary btn-block">' +
body.append('<a target="_blank" href="' + questionUrl + '" class="btn btn-primary">' +
'<%= I18n.t('exercises.implement.flowr.go_to_question') %></a>');
body.find('.btn').on('click', CodeOceanEditor.createEventHandler('editor_flowr_click_question', questionUrl));
@ -112,7 +112,7 @@ CodeOceanEditorCodePilot = {
QaApiOutputBuffer: {'stdout': '', 'stderr': ''},
initializeCodePilot: function () {
if ($('#questions-column').isPresent() && (typeof QaApi != 'undefined') && QaApi.isBrowserSupported()) {
if ($('#questions-column').isPresent() && (typeof QaApi != 'undefined')) {
$('#editor-column').addClass('col-md-10').removeClass('col-md-12');
$('#questions-column').addClass('col-md-2');
@ -161,7 +161,7 @@ CodeOceanEditorRequestForComments = {
this.createSubmission($('#requestComments'), null, createRequestForComments.bind(this));
$('#comment-modal').modal('hide');
bootstrap.Modal.getInstance($('#comment-modal')).hide();
$('#question').val('');
// we disabled the button to prevent that the user spams RFCs, but decided against this now.
//var button = $('#requestComments');

View File

@ -37,15 +37,16 @@ CodeOceanEditorTurtle = {
},
showCanvas: function () {
if ($('#turtlediv').isPresent() && this.turtlecanvas.hasClass('d-none')) {
this.turtlecanvas.removeClass('d-none');
const turtlediv = $('#turtlediv');
if (turtlediv.isPresent() && turtlediv.hasClass('d-none')) {
turtlediv.removeClass('d-none');
}
},
hideCanvas: function () {
const turtlecanvas = $('#turtlecanvas');
if ($('#turtlediv').isPresent() && !turtlecanvas.hasClass('d-none')) {
turtlecanvas.addClass('d-none');
const turtlediv = $('#turtlediv');
if (turtlediv.isPresent() && !turtlediv.hasClass('d-none')) {
turtlediv.addClass('d-none');
}
}

View File

@ -172,7 +172,7 @@ $(document).on('turbolinks:load', function() {
if (collectionExercises.indexOf(exercise.id) === -1) {
// only add exercises that are not already contained in the collection
var template = '<tr data-id="' + exercise.id + '">' +
'<td><span class="fa fa-bars"></span></td>' +
'<td><span class="fa-solid fa-bars"></span></td>' +
'<td>' + exercise.title + '</td>' +
'<td><a href="/exercises/' + exercise.id + '"><%= I18n.t('shared.show') %></td>' +
'<td><a class="remove-exercise" href="#"><%= I18n.t('shared.destroy') %></td></tr>';
@ -187,7 +187,7 @@ $(document).on('turbolinks:load', function() {
for (var i = 0; i < selectedExercises.length; i++) {
addExercise(selectedExercises[i].value, selectedExercises[i].label);
}
$('#add-exercise-modal').modal('hide')
bootstrap.Modal.getInstance($('#add-exercise-modal')).hide();
updateExerciseList();
addExercisesForm.find('select').val('').trigger("chosen:updated");
});

View File

@ -123,7 +123,7 @@ $(document).on('turbolinks:load', function () {
var buildCheckboxes = function () {
$('tbody tr').each(function (index, element) {
var td = $('td.public', element);
var checkbox = $('<input>', {
var checkbox = $('<input class="form-check-input">', {
checked: td.data('value'),
type: 'checkbox'
});
@ -225,9 +225,9 @@ $(document).on('turbolinks:load', function () {
const tip = {id: id, title: title}
const template =
'<div class="list-group-item d-block" data-tip-id=' + tip.id + ' data-id="">' +
'<span class="fa fa-bars mr-3"></span>' + tip.title +
'<a class="fa fa-eye ml-2" href="/tips/' + tip.id + '" target="_blank"></a>' +
'<a class="fa fa-times ml-2 remove-tip" href="#""></a>' +
'<span class="fa-solid fa-bars me-3"></span>' + tip.title +
'<a class="fa-regular fa-eye ms-2" href="/tips/' + tip.id + '" target="_blank"></a>' +
'<a class="fa-solid fa-xmark ms-2 remove-tip" href="#""></a>' +
'<div class="list-group nested-sortable-list"></div>' +
'</div>';
const tipList = $('#tip-list').append(template);
@ -243,7 +243,7 @@ $(document).on('turbolinks:load', function () {
for (let i = 0; i < selectedTips.length; i++) {
addTip(selectedTips[i].value, selectedTips[i].label);
}
$('#add-tips-modal').modal('hide')
bootstrap.Modal.getInstance($('#add-tips-modal')).hide();
updateTipsJSON();
chosenInputTips.val('').trigger("chosen:updated");
});
@ -257,7 +257,7 @@ $(document).on('turbolinks:load', function () {
var highlightCode = function () {
$('pre code').each(function (index, element) {
hljs.highlightBlock(element);
hljs.highlightElement(element);
});
};
@ -328,10 +328,7 @@ $(document).on('turbolinks:load', function () {
var observeExportButtons = function () {
$('.export-start').on('click', function (e) {
e.preventDefault();
$('#export-modal').modal({
height: 250
});
$('#export-modal').modal('show');
new bootstrap.Modal($('#export-modal')).show();
exportExerciseStart($(this).data().exerciseId);
});
$('body').on('click', '.export-retry-button', function () {
@ -382,7 +379,7 @@ $(document).on('turbolinks:load', function () {
if (response.status == 'success') {
$messageDiv.addClass('export-success');
setTimeout((function () {
$('#export-modal').modal('hide');
bootstrap.Modal.getInstance($('#export-modal')).hide();
$messageDiv.html('').removeClass('export-success');
}), 3000);
} else {
@ -396,7 +393,7 @@ $(document).on('turbolinks:load', function () {
};
var overrideTextareaTabBehavior = function () {
$('.form-group textarea[name$="[content]"]').on('keydown', function (event) {
$('.mb-3 textarea[name$="[content]"]').on('keydown', function (event) {
if (event.which === TAB_KEY_CODE) {
event.preventDefault();
insertTabAtCursor($(this));

View File

@ -9,7 +9,7 @@ $(document).on('turbolinks:load', function() {
event.preventDefault();
if (!$(this).hasClass('disabled')) {
var parent = $(this).parents('.form-group');
var parent = $(this).parents('.mb-3');
var original_input = parent.find('.original-input');
var alternative_input = parent.find('.alternative-input');

View File

@ -1,3 +0,0 @@
/*! modernizr 3.6.0 (Custom Build) | MIT *
* https://modernizr.com/download/?-eventsource-urlparser-websockets-setclasses !*/
!function(e,n,s){function o(e,n){return typeof e===n}function t(){var e,n,s,t,a,c,f;for(var l in r)if(r.hasOwnProperty(l)){if(e=[],n=r[l],n.name&&(e.push(n.name.toLowerCase()),n.options&&n.options.aliases&&n.options.aliases.length))for(s=0;s<n.options.aliases.length;s++)e.push(n.options.aliases[s].toLowerCase());for(t=o(n.fn,"function")?n.fn():n.fn,a=0;a<e.length;a++)c=e[a],f=c.split("."),1===f.length?Modernizr[f[0]]=t:(!Modernizr[f[0]]||Modernizr[f[0]]instanceof Boolean||(Modernizr[f[0]]=new Boolean(Modernizr[f[0]])),Modernizr[f[0]][f[1]]=t),i.push((t?"":"no-")+f.join("-"))}}function a(e){var n=u.className,s=Modernizr._config.classPrefix||"";if(d&&(n=n.baseVal),Modernizr._config.enableJSClass){var o=new RegExp("(^|\\s)"+s+"no-js(\\s|$)");n=n.replace(o,"$1"+s+"js$2")}Modernizr._config.enableClasses&&(n+=" "+s+e.join(" "+s),d?u.className.baseVal=n:u.className=n)}var i=[],r=[],c={_version:"3.6.0",_config:{classPrefix:"",enableClasses:!0,enableJSClass:!0,usePrefixes:!0},_q:[],on:function(e,n){var s=this;setTimeout(function(){n(s[e])},0)},addTest:function(e,n,s){r.push({name:e,fn:n,options:s})},addAsyncTest:function(e){r.push({name:null,fn:e})}},Modernizr=function(){};Modernizr.prototype=c,Modernizr=new Modernizr;var f=!1;try{f="WebSocket"in e&&2===e.WebSocket.CLOSING}catch(l){}Modernizr.addTest("websockets",f),Modernizr.addTest("urlparser",function(){var e;try{return e=new URL("http://modernizr.com/"),"http://modernizr.com/"===e.href}catch(n){return!1}});var u=n.documentElement,d="svg"===u.nodeName.toLowerCase();Modernizr.addTest("eventsource","EventSource"in e),t(),a(i),delete c.addTest,delete c.addAsyncTest;for(var p=0;p<Modernizr._q.length;p++)Modernizr._q[p]();e.Modernizr=Modernizr}(window,document);

View File

@ -29,12 +29,12 @@
// entering links.
var linkDialogTitle = "<%= I18n.t('components.markdown_editor.insert_link.dialog_title', default: 'Insert link') %>";
var linkInputLabel = "<%= I18n.t('components.markdown_editor.insert_link.input_label', default: 'Link URL') %>";
var linkInputPlaceholder = "http://example.com/ \"optional title\"";
var linkInputPlaceholder = "https://example.com/ \"optional title\"";
var linkInputHelp = "<%= I18n.t('components.markdown_editor.insert_link.input_help', default: 'Enter URL to point link to and optional title to display when mouse is placed over the link') %>";
var imageDialogTitle = "<%= I18n.t('components.markdown_editor.insert_image.dialog_title', default: 'Insert image') %>";
var imageInputLabel = "<%= I18n.t('components.markdown_editor.insert_image.input_label', default: 'Image URL') %>";
var imageInputPlaceholder = "http://example.com/images/diagram.jpg \"optional title\"";
var imageInputPlaceholder = "https://example.com/images/diagram.jpg \"optional title\"";
var imageInputHelp = "<%= I18n.t('components.markdown_editor.insert_link.input_help', default: 'Enter URL where image is located and optional title to display when mouse is placed over the image') %>";
var defaultHelpHoverTitle = "Markdown Editing Help";
@ -193,7 +193,7 @@
var regexText;
var replacementText;
// chrome bug ... documented at: http://meta.stackoverflow.com/questions/63307/blockquote-glitch-in-editor-in-chrome-6-and-7/65985#65985
// chrome bug ... documented at: https://meta.stackoverflow.com/questions/63307/blockquote-glitch-in-editor-in-chrome-6-and-7/65985#65985
if (navigator.userAgent.match(/Chrome/)) {
"X".match(/()./);
}
@ -1018,7 +1018,7 @@
text = 'http://' + text;
}
$(dialog).modal('hide');
bootstrap.Modal.getInstance($(dialog)).hide();
callback(text);
return false;
@ -1032,7 +1032,7 @@
// <div class="modal-dialog">
// <div class="modal-content">
// <div class="modal-header">
// <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
// <button type="button" class="close" data-bs-dismiss="modal" aria-hidden="true">&times;</button>
// <h3 class="modal-title">Modal title</h3>
// </div>
// <div class="modal-body">
@ -1062,7 +1062,7 @@
// The header.
var header = doc.createElement("div");
header.className = "modal-header";
header.innerHTML = '<h3 class="modal-title">'+title+'</h3> <button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>';
header.innerHTML = '<h3 class="modal-title">'+title+'</h3> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-hidden="true"></button>';
dialogContent.appendChild(header);
// The body.
@ -1082,7 +1082,7 @@
// The input text box
var formGroup = doc.createElement("div");
formGroup.className = "form-group";
formGroup.className = "mb-3";
form.appendChild(formGroup);
var label = doc.createElement("label");
@ -1144,15 +1144,15 @@
range.select();
}
$(dialog).on('shown', function () {
$(dialog).on('shown.bs.modal', function () {
input.focus();
});
$(dialog).on('hidden', function () {
$(dialog).on('hidden.bs.modal', function () {
dialog.parentNode.removeChild(dialog);
});
$(dialog).modal()
new bootstrap.Modal($(dialog)).show();
}, 0);
};
@ -1360,8 +1360,8 @@
button.appendChild(buttonImage);
button.id = id + postfix;
button.title = title;
button.setAttribute("data-toggle", "tooltip");
button.setAttribute("data-placement", "top");
button.setAttribute("data-bs-toggle", "tooltip");
button.setAttribute("data-bs-placement", "top");
if (textOp)
button.textOp = textOp;
setupButton(button, true);
@ -1381,51 +1381,51 @@
};
var group1 = makeGroup(1);
buttons.bold = makeButton("wmd-bold-button", "<%= I18n.t('components.markdown_editor.bold.button_title', default: 'Bold (Ctrl+B)') %>", "m-1 fa fa-bold", bindCommand("doBold"), group1);
buttons.italic = makeButton("wmd-italic-button", "<%= I18n.t('components.markdown_editor.italic.button_title', default: 'Italic (Ctrl+I)') %>", "m-1 fa fa-italic", bindCommand("doItalic"), group1);
buttons.bold = makeButton("wmd-bold-button", "<%= I18n.t('components.markdown_editor.bold.button_title', default: 'Bold (Ctrl+B)') %>", "m-1 fa-solid fa-bold", bindCommand("doBold"), group1);
buttons.italic = makeButton("wmd-italic-button", "<%= I18n.t('components.markdown_editor.italic.button_title', default: 'Italic (Ctrl+I)') %>", "m-1 fa-solid fa-italic", bindCommand("doItalic"), group1);
var group2 = makeGroup(2);
buttons.link = makeButton("wmd-link-button", "<%= I18n.t('components.markdown_editor.insert_link.button_title', default: 'Link (Ctrl+L)') %>", "m-1 fa fa-link", bindCommand(function (chunk, postProcessing) {
buttons.link = makeButton("wmd-link-button", "<%= I18n.t('components.markdown_editor.insert_link.button_title', default: 'Link (Ctrl+L)') %>", "m-1 fa-solid fa-link", bindCommand(function (chunk, postProcessing) {
return this.doLinkOrImage(chunk, postProcessing, false);
}), group2);
buttons.image = makeButton("wmd-image-button", "<%= I18n.t('components.markdown_editor.insert_image.button_title', default: 'Image (Ctrl+G)') %>", "m-1 fa fa-picture-o", bindCommand(function (chunk, postProcessing) {
buttons.image = makeButton("wmd-image-button", "<%= I18n.t('components.markdown_editor.insert_image.button_title', default: 'Image (Ctrl+G)') %>", "m-1 fa-regular fa-image", bindCommand(function (chunk, postProcessing) {
return this.doLinkOrImage(chunk, postProcessing, true);
}), group2);
buttons.quote = makeButton("wmd-quote-button", "<%= I18n.t('components.markdown_editor.blockquoute.button_title', default: 'Blockquote (Ctrl+Q)') %>", "m-1 fa fa-quote-left", bindCommand("doBlockquote"), group2);
buttons.code = makeButton("wmd-code-button", "<%= I18n.t('components.markdown_editor.code_sample.button_title', default: 'Code Sample (Ctrl+K)') %>", "m-1 fa fa-code", bindCommand("doCode"), group2);
buttons.quote = makeButton("wmd-quote-button", "<%= I18n.t('components.markdown_editor.blockquoute.button_title', default: 'Blockquote (Ctrl+Q)') %>", "m-1 fa-solid fa-quote-left", bindCommand("doBlockquote"), group2);
buttons.code = makeButton("wmd-code-button", "<%= I18n.t('components.markdown_editor.code_sample.button_title', default: 'Code Sample (Ctrl+K)') %>", "m-1 fa-solid fa-code", bindCommand("doCode"), group2);
var group3 = makeGroup(3);
buttons.ulist = makeButton("wmd-ulist-button", "<%= I18n.t('components.markdown_editor.bulleted_list.button_title', default: 'Bulleted List (Ctrl+U)') %>", "m-1 fa fa-list-ul", bindCommand(function (chunk, postProcessing) {
buttons.ulist = makeButton("wmd-ulist-button", "<%= I18n.t('components.markdown_editor.bulleted_list.button_title', default: 'Bulleted List (Ctrl+U)') %>", "m-1 fa-solid fa-list-ul", bindCommand(function (chunk, postProcessing) {
this.doList(chunk, postProcessing, false);
}), group3);
buttons.olist = makeButton("wmd-olist-button", "<%= I18n.t('components.markdown_editor.numbered_list.button_title', default: 'Numbered List (Ctrl+O)') %>", "m-1 fa fa-list-ol", bindCommand(function (chunk, postProcessing) {
buttons.olist = makeButton("wmd-olist-button", "<%= I18n.t('components.markdown_editor.numbered_list.button_title', default: 'Numbered List (Ctrl+O)') %>", "m-1 fa-solid fa-list-ol", bindCommand(function (chunk, postProcessing) {
this.doList(chunk, postProcessing, true);
}), group3);
buttons.heading = makeButton("wmd-heading-button", "<%= I18n.t('components.markdown_editor.heading.button_title', default: 'Heading (Ctrl+H)') %>", "m-1 fa fa-font", bindCommand("doHeading"), group3);
buttons.heading = makeButton("wmd-heading-button", "<%= I18n.t('components.markdown_editor.heading.button_title', default: 'Heading (Ctrl+H)') %>", "m-1 fa-solid fa-font", bindCommand("doHeading"), group3);
var group4 = makeGroup(4);
buttons.undo = makeButton("wmd-undo-button", "<%= I18n.t('components.markdown_editor.undo.button_title', default: 'Undo (Ctrl+Z)') %>", "m-1 fa fa-undo", null, group4);
buttons.undo = makeButton("wmd-undo-button", "<%= I18n.t('components.markdown_editor.undo.button_title', default: 'Undo (Ctrl+Z)') %>", "m-1 fa-solid fa-arrow-rotate-left", null, group4);
buttons.undo.execute = function (manager) { if (manager) manager.undo(); };
var redoTitle = /win/.test(nav.platform.toLowerCase()) ?
"<%= I18n.t('components.markdown_editor.redo.button_title.win', default: 'Redo (Ctrl+Y)') %>" :
"<%= I18n.t('components.markdown_editor.redo.button_title.other', default: 'Redo (Ctrl+Shift+Z)') %>"; // mac and other non-Windows platforms
buttons.redo = makeButton("wmd-redo-button", redoTitle, "m-1 fa fa-repeat", null, group4);
buttons.redo = makeButton("wmd-redo-button", redoTitle, "m-1 fa-solid fa-arrow-rotate-right", null, group4);
buttons.redo.execute = function (manager) { if (manager) manager.redo(); };
if (helpOptions) {
var group5 = makeGroup(5);
group5.className = group5.className + " ml-auto";
group5.className = group5.className + " ms-auto";
var helpButton = document.createElement("button");
var helpButtonImage = document.createElement("i");
helpButtonImage.className = "m-1 fa fa-info";
helpButtonImage.className = "m-1 fa-solid fa-info";
helpButton.appendChild(helpButtonImage);
helpButton.className = "btn btn-info btn-sm";
helpButton.id = "wmd-help-button" + postfix;
helpButton.isHelp = true;
helpButton.setAttribute("data-toggle", "tooltip");
helpButton.setAttribute("data-placement", "top");
helpButton.setAttribute("data-bs-toggle", "tooltip");
helpButton.setAttribute("data-bs-placement", "top");
helpButton.title = helpOptions.title || defaultHelpHoverTitle;
helpButton.onclick = helpOptions.handler;
@ -1793,7 +1793,7 @@
//
// Since this is essentially a backwards-moving regex, it's susceptible to
// catastrophic backtracking and can cause the browser to hang;
// see e.g. http://meta.stackoverflow.com/questions/9807.
// see e.g. https://meta.stackoverflow.com/questions/9807.
//
// Hence we replaced this by a simple state machine that just goes through the
// lines and checks for a), b), and c).

View File

@ -24,7 +24,7 @@ createPagedownEditor = function( selector, context ) {
Markdown.Extra.init(converter);
const help = {
handler() {
window.open('http://daringfireball.net/projects/markdown/syntax');
window.open('https://daringfireball.net/projects/markdown/syntax');
return false;
},
title: "<%= I18n.t('components.markdown_editor.help', default: 'Markdown Editing Help') %>"
@ -32,7 +32,7 @@ createPagedownEditor = function( selector, context ) {
const editor = new Markdown.Editor(converter, attr, help);
editor.run();
$('[data-toggle="tooltip"]').tooltip();
$('[data-bs-toggle="tooltip"]').tooltip();
return $(input).data('is_rendered', true);
});
};

View File

@ -29,10 +29,14 @@ $(document).on('turbolinks:load', function () {
};
const handleResponse = function (response) {
// Always print stdout and stderr
printOutput(response);
// If an error occurred, print it too
if (response.status === 'timeout') {
printTimeout(response);
} else {
printOutput(response);
} else if (response.status === 'out_of_memory') {
printOutOfMemory(response);
}
};
@ -71,12 +75,19 @@ $(document).on('turbolinks:load', function () {
};
const printTimeout = function (output) {
const element = $.append('<p>');
const element = $('<p>');
element.addClass('text-danger');
element.text($('#shell').data('message-timeout'));
$('#output').append(element);
};
const printOutOfMemory = function (output) {
const element = $('<p>');
element.addClass('text-danger');
element.text($('#shell').data('message-out-of-memory'));
$('#output').append(element);
};
if ($('#shell').isPresent()) {
const command = $('#command')
command.focus();

View File

@ -1,498 +0,0 @@
$(document).on('turbolinks:load', function(){
(function vendorTableSorter(){
/*
SortTable
version 2
7th April 2007
Stuart Langridge, http://www.kryogenix.org/code/browser/sorttable/
Instructions:
Download this file
Add <script src="sorttable.js"></script> to your HTML
Add class="sortable" to any table you'd like to make sortable
Click on the headers to sort
Thanks to many, many people for contributions and suggestions.
Licenced as X11: http://www.kryogenix.org/code/browser/licence.html
This basically means: do what you want with it.
*/
var stIsIE = /*@cc_on!@*/false;
sorttable = {
init: function() {
// quit if this function has already been called
if (arguments.callee.done) return;
// flag this function so we don't do the same thing twice
arguments.callee.done = true;
// kill the timer
if (_timer) clearInterval(_timer);
if (!document.createElement || !document.getElementsByTagName) return;
sorttable.DATE_RE = /^(\d\d?)[\/\.-](\d\d?)[\/\.-]((\d\d)?\d\d)$/;
forEach(document.getElementsByTagName('table'), function(table) {
if (table.className.search(/\bsortable\b/) != -1) {
sorttable.makeSortable(table);
}
});
},
makeSortable: function(table) {
if (table.getElementsByTagName('thead').length == 0) {
// table doesn't have a tHead. Since it should have, create one and
// put the first table row in it.
the = document.createElement('thead');
the.appendChild(table.rows[0]);
table.insertBefore(the,table.firstChild);
}
// Safari doesn't support table.tHead, sigh
if (table.tHead == null) table.tHead = table.getElementsByTagName('thead')[0];
if (table.tHead.rows.length != 1) return; // can't cope with two header rows
// Sorttable v1 put rows with a class of "sortbottom" at the bottom (as
// "total" rows, for example). This is B&R, since what you're supposed
// to do is put them in a tfoot. So, if there are sortbottom rows,
// for backwards compatibility, move them to tfoot (creating it if needed).
sortbottomrows = [];
for (var i=0; i<table.rows.length; i++) {
if (table.rows[i].className.search(/\bsortbottom\b/) != -1) {
sortbottomrows[sortbottomrows.length] = table.rows[i];
}
}
if (sortbottomrows) {
if (table.tFoot == null) {
// table doesn't have a tfoot. Create one.
tfo = document.createElement('tfoot');
table.appendChild(tfo);
}
for (var i=0; i<sortbottomrows.length; i++) {
tfo.appendChild(sortbottomrows[i]);
}
delete sortbottomrows;
}
// work through each column and calculate its type
headrow = table.tHead.rows[0].cells;
for (var i=0; i<headrow.length; i++) {
// manually override the type with a sorttable_type attribute
if (!headrow[i].className.match(/\bsorttable_nosort\b/)) { // skip this col
mtch = headrow[i].className.match(/\bsorttable_([a-z0-9]+)\b/);
if (mtch) { override = mtch[1]; }
if (mtch && typeof sorttable["sort_"+override] == 'function') {
headrow[i].sorttable_sortfunction = sorttable["sort_"+override];
} else {
headrow[i].sorttable_sortfunction = sorttable.guessType(table,i);
}
// make it clickable to sort
headrow[i].sorttable_columnindex = i;
headrow[i].sorttable_tbody = table.tBodies[0];
dean_addEvent(headrow[i],"click", sorttable.innerSortFunction = function(e) {
if (this.className.search(/\bsorttable_sorted\b/) != -1) {
// if we're already sorted by this column, just
// reverse the table, which is quicker
sorttable.reverse(this.sorttable_tbody);
this.className = this.className.replace('sorttable_sorted',
'sorttable_sorted_reverse');
this.removeChild(document.getElementById('sorttable_sortfwdind'));
sortrevind = document.createElement('span');
sortrevind.id = "sorttable_sortrevind";
sortrevind.innerHTML = stIsIE ? '&nbsp<font face="webdings">5</font>' : '&nbsp;&#x25B4;';
this.appendChild(sortrevind);
return;
}
if (this.className.search(/\bsorttable_sorted_reverse\b/) != -1) {
// if we're already sorted by this column in reverse, just
// re-reverse the table, which is quicker
sorttable.reverse(this.sorttable_tbody);
this.className = this.className.replace('sorttable_sorted_reverse',
'sorttable_sorted');
this.removeChild(document.getElementById('sorttable_sortrevind'));
sortfwdind = document.createElement('span');
sortfwdind.id = "sorttable_sortfwdind";
sortfwdind.innerHTML = stIsIE ? '&nbsp<font face="webdings">6</font>' : '&nbsp;&#x25BE;';
this.appendChild(sortfwdind);
return;
}
// remove sorttable_sorted classes
theadrow = this.parentNode;
forEach(theadrow.childNodes, function(cell) {
if (cell.nodeType == 1) { // an element
cell.className = cell.className.replace('sorttable_sorted_reverse','');
cell.className = cell.className.replace('sorttable_sorted','');
}
});
sortfwdind = document.getElementById('sorttable_sortfwdind');
if (sortfwdind) { sortfwdind.parentNode.removeChild(sortfwdind); }
sortrevind = document.getElementById('sorttable_sortrevind');
if (sortrevind) { sortrevind.parentNode.removeChild(sortrevind); }
this.className += ' sorttable_sorted';
sortfwdind = document.createElement('span');
sortfwdind.id = "sorttable_sortfwdind";
sortfwdind.innerHTML = stIsIE ? '&nbsp<font face="webdings">6</font>' : '&nbsp;&#x25BE;';
this.appendChild(sortfwdind);
// build an array to sort. This is a Schwartzian transform thing,
// i.e., we "decorate" each row with the actual sort key,
// sort based on the sort keys, and then put the rows back in order
// which is a lot faster because you only do getInnerText once per row
row_array = [];
col = this.sorttable_columnindex;
rows = this.sorttable_tbody.rows;
for (var j=0; j<rows.length; j++) {
row_array[row_array.length] = [sorttable.getInnerText(rows[j].cells[col]), rows[j]];
}
/* If you want a stable sort, uncomment the following line */
//sorttable.shaker_sort(row_array, this.sorttable_sortfunction);
/* and comment out this one */
row_array.sort(this.sorttable_sortfunction);
tb = this.sorttable_tbody;
for (var j=0; j<row_array.length; j++) {
tb.appendChild(row_array[j][1]);
}
delete row_array;
});
}
}
},
guessType: function(table, column) {
// guess the type of a column based on its first non-blank row
sortfn = sorttable.sort_alpha;
for (var i=0; i<table.tBodies[0].rows.length; i++) {
text = sorttable.getInnerText(table.tBodies[0].rows[i].cells[column]);
if (text != '') {
if (text.match(/^-?[£$¤]?[\d,.]+%?$/)) {
return sorttable.sort_numeric;
}
// check for a date: dd/mm/yyyy or dd/mm/yy
// can have / or . or - as separator
// can be mm/dd as well
possdate = text.match(sorttable.DATE_RE)
if (possdate) {
// looks like a date
first = parseInt(possdate[1]);
second = parseInt(possdate[2]);
if (first > 12) {
// definitely dd/mm
return sorttable.sort_ddmm;
} else if (second > 12) {
return sorttable.sort_mmdd;
} else {
// looks like a date, but we can't tell which, so assume
// that it's dd/mm (English imperialism!) and keep looking
sortfn = sorttable.sort_ddmm;
}
}
}
}
return sortfn;
},
getInnerText: function(node) {
// gets the text we want to use for sorting for a cell.
// strips leading and trailing whitespace.
// this is *not* a generic getInnerText function; it's special to sorttable.
// for example, you can override the cell text with a customkey attribute.
// it also gets .value for <input> fields.
if (!node) return "";
hasInputs = (typeof node.getElementsByTagName == 'function') &&
node.getElementsByTagName('input').length;
if (node.getAttribute("sorttable_customkey") != null) {
return node.getAttribute("sorttable_customkey");
}
else if (typeof node.textContent != 'undefined' && !hasInputs) {
return node.textContent.replace(/^\s+|\s+$/g, '');
}
else if (typeof node.innerText != 'undefined' && !hasInputs) {
return node.innerText.replace(/^\s+|\s+$/g, '');
}
else if (typeof node.text != 'undefined' && !hasInputs) {
return node.text.replace(/^\s+|\s+$/g, '');
}
else {
switch (node.nodeType) {
case 3:
if (node.nodeName.toLowerCase() == 'input') {
return node.value.replace(/^\s+|\s+$/g, '');
}
case 4:
return node.nodeValue.replace(/^\s+|\s+$/g, '');
break;
case 1:
case 11:
var innerText = '';
for (var i = 0; i < node.childNodes.length; i++) {
innerText += sorttable.getInnerText(node.childNodes[i]);
}
return innerText.replace(/^\s+|\s+$/g, '');
break;
default:
return '';
}
}
},
reverse: function(tbody) {
// reverse the rows in a tbody
newrows = [];
for (var i=0; i<tbody.rows.length; i++) {
newrows[newrows.length] = tbody.rows[i];
}
for (var i=newrows.length-1; i>=0; i--) {
tbody.appendChild(newrows[i]);
}
delete newrows;
},
/* sort functions
each sort function takes two parameters, a and b
you are comparing a[0] and b[0] */
sort_numeric: function(a,b) {
aa = parseFloat(a[0].replace(/[^0-9.-]/g,''));
if (isNaN(aa)) aa = 0;
bb = parseFloat(b[0].replace(/[^0-9.-]/g,''));
if (isNaN(bb)) bb = 0;
return aa-bb;
},
sort_alpha: function(a,b) {
if (a[0]==b[0]) return 0;
if (a[0]<b[0]) return -1;
return 1;
},
sort_ddmm: function(a,b) {
mtch = a[0].match(sorttable.DATE_RE);
y = mtch[3]; m = mtch[2]; d = mtch[1];
if (m.length == 1) m = '0'+m;
if (d.length == 1) d = '0'+d;
dt1 = y+m+d;
mtch = b[0].match(sorttable.DATE_RE);
y = mtch[3]; m = mtch[2]; d = mtch[1];
if (m.length == 1) m = '0'+m;
if (d.length == 1) d = '0'+d;
dt2 = y+m+d;
if (dt1==dt2) return 0;
if (dt1<dt2) return -1;
return 1;
},
sort_mmdd: function(a,b) {
mtch = a[0].match(sorttable.DATE_RE);
y = mtch[3]; d = mtch[2]; m = mtch[1];
if (m.length == 1) m = '0'+m;
if (d.length == 1) d = '0'+d;
dt1 = y+m+d;
mtch = b[0].match(sorttable.DATE_RE);
y = mtch[3]; d = mtch[2]; m = mtch[1];
if (m.length == 1) m = '0'+m;
if (d.length == 1) d = '0'+d;
dt2 = y+m+d;
if (dt1==dt2) return 0;
if (dt1<dt2) return -1;
return 1;
},
shaker_sort: function(list, comp_func) {
// A stable sort function to allow multi-level sorting of data
// see: http://en.wikipedia.org/wiki/Cocktail_sort
// thanks to Joseph Nahmias
var b = 0;
var t = list.length - 1;
var swap = true;
while(swap) {
swap = false;
for(var i = b; i < t; ++i) {
if ( comp_func(list[i], list[i+1]) > 0 ) {
var q = list[i]; list[i] = list[i+1]; list[i+1] = q;
swap = true;
}
} // for
t--;
if (!swap) break;
for(var i = t; i > b; --i) {
if ( comp_func(list[i], list[i-1]) < 0 ) {
var q = list[i]; list[i] = list[i-1]; list[i-1] = q;
swap = true;
}
} // for
b++;
} // while(swap)
}
}
/* ******************************************************************
Supporting functions: bundled here to avoid depending on a library
****************************************************************** */
// Dean Edwards/Matthias Miller/John Resig
/* for Mozilla/Opera9 */
if (document.addEventListener) {
document.addEventListener("DOMContentLoaded", sorttable.init, false);
}
/* for Internet Explorer */
/*@cc_on @*/
/*@if (@_win32)
document.write("<script id=__ie_onload defer src=javascript:void(0)><\/script>");
var script = document.getElementById("__ie_onload");
script.onreadystatechange = function() {
if (this.readyState == "complete") {
sorttable.init(); // call the onload handler
}
};
/*@end @*/
/* for Safari */
if (/WebKit/i.test(navigator.userAgent)) { // sniff
var _timer = setInterval(function() {
if (/loaded|complete/.test(document.readyState)) {
sorttable.init(); // call the onload handler
}
}, 10);
}
/* for other browsers */
window.onload = sorttable.init;
// written by Dean Edwards, 2005
// with input from Tino Zijdel, Matthias Miller, Diego Perini
// http://dean.edwards.name/weblog/2005/10/add-event/
function dean_addEvent(element, type, handler) {
if (element.addEventListener) {
element.addEventListener(type, handler, false);
} else {
// assign each event handler a unique ID
if (!handler.$$guid) handler.$$guid = dean_addEvent.guid++;
// create a hash table of event types for the element
if (!element.events) element.events = {};
// create a hash table of event handlers for each element/event pair
var handlers = element.events[type];
if (!handlers) {
handlers = element.events[type] = {};
// store the existing event handler (if there is one)
if (element["on" + type]) {
handlers[0] = element["on" + type];
}
}
// store the event handler in the hash table
handlers[handler.$$guid] = handler;
// assign a global event handler to do all the work
element["on" + type] = handleEvent;
}
};
// a counter used to create unique IDs
dean_addEvent.guid = 1;
function removeEvent(element, type, handler) {
if (element.removeEventListener) {
element.removeEventListener(type, handler, false);
} else {
// delete the event handler from the hash table
if (element.events && element.events[type]) {
delete element.events[type][handler.$$guid];
}
}
};
function handleEvent(event) {
var returnValue = true;
// grab the event object (IE uses a global event object)
event = event || fixEvent(((this.ownerDocument || this.document || this).parentWindow || window).event);
// get a reference to the hash table of event handlers
var handlers = this.events[event.type];
// execute each event handler
for (var i in handlers) {
this.$$handleEvent = handlers[i];
if (this.$$handleEvent(event) === false) {
returnValue = false;
}
}
return returnValue;
};
function fixEvent(event) {
// add W3C standard event methods
event.preventDefault = fixEvent.preventDefault;
event.stopPropagation = fixEvent.stopPropagation;
return event;
};
fixEvent.preventDefault = function() {
this.returnValue = false;
};
fixEvent.stopPropagation = function() {
this.cancelBubble = true;
}
// Dean's forEach: http://dean.edwards.name/base/forEach.js
/*
forEach, version 1.0
Copyright 2006, Dean Edwards
License: http://www.opensource.org/licenses/mit-license.php
*/
// array-like enumeration
if (!Array.forEach) { // mozilla already supports this
Array.forEach = function(array, block, context) {
for (var i = 0; i < array.length; i++) {
block.call(context, array[i], i, array);
}
};
}
// generic enumeration
Function.prototype.forEach = function(object, block, context) {
for (var key in object) {
if (typeof this.prototype[key] == "undefined") {
block.call(context, object[key], key, object);
}
}
};
// character enumeration
String.forEach = function(string, block, context) {
Array.forEach(string.split(""), function(chr, index) {
block.call(context, chr, index, string);
});
};
// globally resolve forEach enumeration
var forEach = function(object, block, context) {
if (object) {
var resolve = Object; // default
if (object instanceof Function) {
// functions have a "length" property
resolve = Function;
} else if (object.forEach instanceof Function) {
// the object implements a custom forEach method so use that
object.forEach(block, context);
return;
} else if (typeof object == "string") {
// the object is a string
resolve = String;
} else if (typeof object.length == "number") {
// the object is array-like
resolve = Array;
}
resolve.forEach(object, block, context);
}
};
}());
});

View File

@ -10,6 +10,8 @@ $(document).on('turbolinks:load', function() {
var fileTypeById = {};
var showActiveFile = function() {
$('tr.active').removeClass('active');
$('tr#submission-' + currentSubmission).addClass('active');
var session = editor.getSession();
var fileType = fileTypeById[active_file.file_type_id];
session.setMode(fileType.editor_mode);
@ -81,6 +83,7 @@ $(document).on('turbolinks:load', function() {
$('tr[data-id]>.clickable').each(function(index, element) {
element = $(element);
element.parent().attr('id', 'submission-' + index);
element.click(function() {
slider.val(index);
slider.change()
@ -105,7 +108,7 @@ $(document).on('turbolinks:load', function() {
stopReplay = function() {
clearInterval(playInterval);
playInterval = undefined;
playButton.find('span.fa').removeClass('fa-pause').addClass('fa-play')
playButton.find('span.fa-solid').removeClass('fa-pause').addClass('fa-play')
};
playButton.on('click', function(event) {
@ -124,7 +127,7 @@ $(document).on('turbolinks:load', function() {
stopReplay();
}
}, 1000);
playButton.find('span.fa').removeClass('fa-play').addClass('fa-pause')
playButton.find('span.fa-solid').removeClass('fa-play').addClass('fa-pause')
} else {
stopReplay();
}

View File

@ -160,7 +160,6 @@ $(document).on('turbolinks:load', function() {
groupRanges += groupWidth;
}
while (groupRanges < maximum_minutes);
console.log(maximum_minutes);
var clusterCount = 0,
sum = 0,

View File

@ -12,7 +12,16 @@ h1, h2, h3, h4, h5, h6 {
color: rgba(70, 70, 70, 1);
}
i.fa, i.far, i.fas {
a:not(.dropdown-item, .dropdown-toggle, .dropdown-link, .btn, .page-link), .btn-link {
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
i.fa-solid, i.fa-regular, i.fa-solid {
margin-right: 0.5em;
}
@ -35,6 +44,10 @@ span.caret {
}
}
.btn-default {
--bs-btn-disabled-border-color: transparent;
}
.progress {
margin: 0;
border: 1px solid #CCCCCC;
@ -51,13 +64,17 @@ span.caret {
.navbar {
-webkit-font-smoothing: antialiased;
font-weight: 500;
font-size: 0.85rem;
.dropdown-item {
padding: 1rem 1.5rem;
}
}
.attribute-row + .attribute-row {
margin-top: 0.5em;
}
.badge-pill {
.rounded-pill {
font-size: 100%;
font-weight: 500;
}

View File

@ -29,11 +29,11 @@
border-left-color: #ffffff;
}
.dropdown-submenu.float-left {
.dropdown-submenu.float-start {
float: none;
}
.dropdown-submenu.float-left > .dropdown-menu {
.dropdown-submenu.float-start > .dropdown-menu {
left: -100%;
margin-left: 10px;
-webkit-border-radius: 6px 0 6px 6px;

View File

@ -1,6 +1,6 @@
// Place all the styles related to the Comments controller here.
// They will automatically be included in application.css.
// You can use Sass (SCSS) here: http://sass-lang.com/
// You can use Sass (SCSS) here: https://sass-lang.com/
.ace_gutter-cell.code-ocean_comment {
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH3wIUDTMn077U+gAAANhJREFUOMvV0jFOQkEURuEPfCFKaChwASQWhsIOaxYgW7Ci0c4VuBBM6GmsWIC2JsbCykJiRSD0GMVHMy+ZTPSBlfHvZu7NyZ1zh79O5Zu7Bi7RRz30TDHC7TbACB1UkSe1Kt5xg2FxuRc1jHFcMm0e+k/xgceCCmc4wtcOz17jCs0YMMDnL9ytcBgDVj8ILZO/jgHXiY9t2cdbLHGBFk4KcklqYcXLdAt3wXQ3kVlBhgM84AKvRTFL6O0gM8MTZuH8ggnmZaP18Ix7nO8qI0t+Wi/4yP2bbAA4rSby6Nz4AwAAAABJRU5ErkJggg==");

View File

@ -1,7 +1,3 @@
button i.fa-spin {
display: none;
}
.editor {
height: 100%;
width: 100%;
@ -48,10 +44,6 @@ button i.fa-spin {
vertical-align: bottom;
}
#development-environment {
display: none;
}
#dummy {
display: none;
}
@ -203,8 +195,8 @@ button i.fa-spin {
visibility: visible;
}
.enforce-big-top-margin {
margin-top: 15px !important;
.enforce-big-bottom-margin {
margin-bottom: 15px !important;
}
.enforce-bottom-margin {

View File

@ -40,11 +40,11 @@ input[type='file'] {
}
}
[data-toggle="collapse"] .fa:before {
[data-bs-toggle="collapse"] .fa-solid:before {
content: "\f139";
}
[data-toggle="collapse"].collapsed .fa:before {
[data-bs-toggle="collapse"].collapsed .fa-solid:before {
content: "\f13a";
}

View File

@ -1,3 +1,3 @@
// Place all the styles related to the FileTemplates controller here.
// They will automatically be included in application.css.
// You can use Sass (SCSS) here: http://sass-lang.com/
// You can use Sass (SCSS) here: https://sass-lang.com/

View File

@ -23,12 +23,6 @@
margin-bottom: 1em;
}
.form-group {
&:not(:last-child) {
margin-right: 1em;
}
}
input, select {
min-width: 200px !important;
}

View File

@ -58,6 +58,15 @@ div.negative-result {
box-shadow: 0px 0px 11px 1px rgba(222,0,0,1);
}
tr.active {
filter: brightness(85%);
color: #000000;
}
tr:not(.before_deadline,.within_grace_period,.after_late_deadline) {
background-color: #ffffff;
}
tr.highlight {
border-top: 2px solid rgba(222,0,0,1);
}

View File

@ -12,7 +12,7 @@ class LaExercisesChannel < ApplicationCable::Channel
private
def specific_channel
reject unless StudyGroupPolicy.new(current_user, StudyGroup.find_by(id: params[:study_group_id])).stream_la?
reject unless StudyGroupPolicy.new(current_user, StudyGroup.find(params[:study_group_id])).stream_la?
"la_exercises_#{params[:exercise_id]}_channel_study_group_#{params[:study_group_id]}"
end
end

View File

@ -2,7 +2,7 @@
class ApplicationController < ActionController::Base
include ApplicationHelper
include Pundit
include Pundit::Authorization
MEMBER_ACTIONS = %i[destroy edit show update].freeze
@ -15,9 +15,11 @@ class ApplicationController < ActionController::Base
rescue_from ActionController::InvalidAuthenticityToken, with: :render_csrf_error
def current_user
::NewRelic::Agent.add_custom_attributes(external_user_id: session[:external_user_id],
session_user_id: session[:user_id])
@current_user ||= ExternalUser.find_by(id: session[:external_user_id]) || login_from_session || login_from_other_sources || nil
@current_user ||= ExternalUser.find_by(id: session[:external_user_id]) ||
login_from_session ||
login_from_other_sources ||
login_from_authentication_token ||
nil
end
def require_user!
@ -34,6 +36,13 @@ class ApplicationController < ActionController::Base
end
end
def login_from_authentication_token
token = AuthenticationToken.find_by(shared_secret: params[:token])
return unless token
auto_login(token.user) if token.expire_at.future?
end
def set_sentry_context
return if current_user.blank?
@ -73,7 +82,7 @@ class ApplicationController < ActionController::Base
private :render_error
def switch_locale(&action)
session[:locale] = params[:custom_locale] || params[:locale] || session[:locale]
session[:locale] = sanitize_locale(params[:custom_locale] || params[:locale] || session[:locale])
locale = session[:locale] || I18n.default_locale
Sentry.set_extras(locale: locale)
I18n.with_locale(locale, &action)
@ -98,4 +107,18 @@ class ApplicationController < ActionController::Base
@embed_options
end
private :load_embed_options
# Sanitize given locale.
#
# Return `nil` if the locale is blank or not available.
#
def sanitize_locale(locale)
return if locale.blank?
locale = locale.downcase.to_sym
return unless I18n.available_locales.include?(locale)
locale
end
private :sanitize_locale
end

View File

@ -28,7 +28,7 @@ module CodeOcean
yield if block_given?
path = options[:path].try(:call) || @object
respond_with_valid_object(format, notice: t('shared.object_created', model: @object.class.model_name.human),
path: path, status: :created)
path: path, status: :created)
else
filename = "#{@object.path || ''}/#{@object.name || ''}#{@object.file_type.try(:file_extension) || ''}"
format.html do

View File

@ -44,7 +44,6 @@ class CodeharborLinksController < ApplicationController
def set_codeharbor_link
@codeharbor_link = CodeharborLink.find(params[:id])
@codeharbor_link.user = current_user
authorize!
end

View File

@ -3,9 +3,6 @@
class CommentsController < ApplicationController
before_action :set_comment, only: %i[show update destroy]
# to disable authorization check: comment the line below back in
# skip_after_action :verify_authorized
def authorize!
authorize(@comment || @comments)
end
@ -55,7 +52,7 @@ class CommentsController < ApplicationController
# PATCH/PUT /comments/1.json
def update
if @comment.update(comment_params_without_request_id)
if @comment.update(comment_params_for_update)
render :show, status: :ok, location: @comment
else
render json: @comment.errors, status: :unprocessable_entity
@ -77,6 +74,10 @@ class CommentsController < ApplicationController
@comment = Comment.find(params[:id])
end
def comment_params_for_update
params.require(:comment).permit(:text)
end
def comment_params_without_request_id
comment_params.except :request_id
end

View File

@ -11,7 +11,7 @@ class CommunitySolutionsController < ApplicationController
# GET /community_solutions
def index
@community_solutions = CommunitySolution.all
@community_solutions = CommunitySolution.all.paginate(page: params[:page], per_page: per_page_param)
authorize!
end
@ -85,7 +85,7 @@ class CommunitySolutionsController < ApplicationController
private
def authorize!
authorize(@community_solution)
authorize(@community_solution || @community_solutions)
end
# Use callbacks to share common setup or constraints between actions.

View File

@ -21,7 +21,7 @@ module Lti
# exercise_id.nil? ==> the user has logged out. All session data is to be destroyed
# exercise_id.exists? ==> the user has submitted the results of an exercise to the consumer.
# Only the lti_parameters are deleted.
def clear_lti_session_data(exercise_id = nil, user_id = nil)
def clear_lti_session_data(exercise_id = nil, _user_id = nil)
if exercise_id.nil?
session.delete(:external_user_id)
session.delete(:study_group_id)
@ -29,8 +29,10 @@ module Lti
session.delete(:lti_exercise_id)
session.delete(:lti_parameters_id)
end
LtiParameter.where(external_users_id: user_id,
exercises_id: exercise_id).destroy_all
# March 2022: We temporarily allow reusing the LTI credentials and don't remove them on purpose.
# This allows users to jump between remote and web evaluation with the same behavior.
# LtiParameter.where(external_users_id: user_id, exercises_id: exercise_id).destroy_all
end
private :clear_lti_session_data
@ -136,7 +138,6 @@ module Lti
private :return_to_consumer
def send_score(submission)
::NewRelic::Agent.add_custom_attributes({score: submission.normalized_score, session: session})
unless (0..MAXIMUM_SCORE).cover?(submission.normalized_score)
raise Error.new("Score #{submission.normalized_score} must be between 0 and #{MAXIMUM_SCORE}!")
end

View File

@ -129,12 +129,7 @@ module RedirectBehavior
lti_parameters_id: session[:lti_parameters_id]
)
lti_parameter = LtiParameter.where(external_users_id: @submission.user_id,
exercises_id: @submission.exercise_id).last
path = lti_return_path(submission_id: @submission.id,
url: consumer_return_url(build_tool_provider(consumer: @submission.user.consumer,
parameters: lti_parameter&.lti_parameters)))
path = lti_return_path(submission_id: @submission.id)
clear_lti_session_data(@submission.exercise_id, @submission.user_id)
respond_to do |format|
format.html { redirect_to(path) }

View File

@ -28,7 +28,7 @@ class ConsumersController < ApplicationController
private :consumer_params
def index
@consumers = Consumer.paginate(page: params[:page])
@consumers = Consumer.paginate(page: params[:page], per_page: per_page_param)
authorize!
end

View File

@ -12,7 +12,7 @@ class ErrorTemplateAttributesController < ApplicationController
# GET /error_template_attributes.json
def index
@error_template_attributes = ErrorTemplateAttribute.all.order('important DESC', :key,
:id).paginate(page: params[:page])
:id).paginate(page: params[:page], per_page: per_page_param)
authorize!
end
@ -42,7 +42,7 @@ class ErrorTemplateAttributesController < ApplicationController
respond_to do |format|
if @error_template_attribute.save
format.html do
redirect_to @error_template_attribute, notice: 'Error template attribute was successfully created.'
redirect_to @error_template_attribute, notice: t('shared.object_created', model: @error_template_attribute.class.model_name.human)
end
format.json { render :show, status: :created, location: @error_template_attribute }
else
@ -59,7 +59,7 @@ class ErrorTemplateAttributesController < ApplicationController
respond_to do |format|
if @error_template_attribute.update(error_template_attribute_params)
format.html do
redirect_to @error_template_attribute, notice: 'Error template attribute was successfully updated.'
redirect_to @error_template_attribute, notice: t('shared.object_updated', model: @error_template_attribute.class.model_name.human)
end
format.json { render :show, status: :ok, location: @error_template_attribute }
else
@ -76,7 +76,7 @@ class ErrorTemplateAttributesController < ApplicationController
@error_template_attribute.destroy
respond_to do |format|
format.html do
redirect_to error_template_attributes_url, notice: 'Error template attribute was successfully destroyed.'
redirect_to error_template_attributes_url, notice: t('shared.object_destroyed', model: @error_template_attribute.class.model_name.human)
end
format.json { head :no_content }
end

View File

@ -11,7 +11,7 @@ class ErrorTemplatesController < ApplicationController
# GET /error_templates
# GET /error_templates.json
def index
@error_templates = ErrorTemplate.all.order(:execution_environment_id, :name).paginate(page: params[:page])
@error_templates = ErrorTemplate.all.order(:execution_environment_id, :name).paginate(page: params[:page], per_page: per_page_param)
authorize!
end
@ -40,7 +40,7 @@ class ErrorTemplatesController < ApplicationController
respond_to do |format|
if @error_template.save
format.html { redirect_to @error_template, notice: 'Error template was successfully created.' }
format.html { redirect_to @error_template, notice: t('shared.object_created', model: @error_template.class.model_name.human) }
format.json { render :show, status: :created, location: @error_template }
else
format.html { render :new }
@ -55,7 +55,7 @@ class ErrorTemplatesController < ApplicationController
authorize!
respond_to do |format|
if @error_template.update(error_template_params)
format.html { redirect_to @error_template, notice: 'Error template was successfully updated.' }
format.html { redirect_to @error_template, notice: t('shared.object_updated', model: @error_template.class.model_name.human) }
format.json { render :show, status: :ok, location: @error_template }
else
format.html { render :edit }
@ -70,14 +70,14 @@ class ErrorTemplatesController < ApplicationController
authorize!
@error_template.destroy
respond_to do |format|
format.html { redirect_to error_templates_url, notice: 'Error template was successfully destroyed.' }
format.html { redirect_to error_templates_url, notice: t('shared.object_destroyed', model: @error_template.class.model_name.human) }
format.json { head :no_content }
end
end
def add_attribute
authorize!
@error_template.error_template_attributes << ErrorTemplateAttribute.find(params['error_template_attribute_id'])
@error_template.error_template_attributes << ErrorTemplateAttribute.find(params[:error_template_attribute_id])
respond_to do |format|
format.html { redirect_to @error_template }
format.json { head :no_content }
@ -86,7 +86,7 @@ class ErrorTemplatesController < ApplicationController
def remove_attribute
authorize!
@error_template.error_template_attributes.delete(ErrorTemplateAttribute.find(params['error_template_attribute_id']))
@error_template.error_template_attributes.delete(ErrorTemplateAttribute.find(params[:error_template_attribute_id]))
respond_to do |format|
format.html { redirect_to @error_template }
format.json { head :no_content }

View File

@ -30,7 +30,7 @@ class ExecutionEnvironmentsController < ApplicationController
def execute_command
runner = Runner.for(current_user, @execution_environment)
output = runner.execute_command(params[:command], raise_exception: false)
render json: output
render json: output.except(:messages)
end
def working_time_query
@ -44,7 +44,7 @@ class ExecutionEnvironmentsController < ApplicationController
FROM
(SELECT user_id,
exercise_id,
CASE WHEN working_time >= #{StatisticsHelper::WORKING_TIME_DELTA_IN_SQL_INTERVAL} THEN '0' ELSE working_time END AS working_time_new
CASE WHEN #{StatisticsHelper.working_time_larger_delta} THEN '0' ELSE working_time END AS working_time_new
FROM
(SELECT user_id,
exercise_id,
@ -121,7 +121,7 @@ class ExecutionEnvironmentsController < ApplicationController
private :execution_environment_params
def index
@execution_environments = ExecutionEnvironment.all.includes(:user).order(:name).paginate(page: params[:page])
@execution_environments = ExecutionEnvironment.all.includes(:user).order(:name).paginate(page: params[:page], per_page: per_page_param)
authorize!
end
@ -158,7 +158,7 @@ class ExecutionEnvironmentsController < ApplicationController
def show
if @execution_environment.testing_framework?
@testing_framework_adapter = Kernel.const_get(@execution_environment.testing_framework)
@testing_framework_adapter = TestingFrameworkAdapter.descendants.find {|klass| klass.name == @execution_environment.testing_framework }
end
end
@ -172,8 +172,7 @@ class ExecutionEnvironmentsController < ApplicationController
begin
Runner.strategy_class.sync_environment(@execution_environment)
rescue Runner::Error => e
Rails.logger.debug { "Runner error while synchronizing execution environment with id #{@execution_environment.id}: #{e.message}" }
Sentry.capture_exception(e)
Rails.logger.warn { "Runner error while synchronizing execution environment with id #{@execution_environment.id}: #{e.message}" }
redirect_to @execution_environment, alert: t('execution_environments.index.synchronize.failure', error: e.message)
else
redirect_to @execution_environment, notice: t('execution_environments.index.synchronize.success')

View File

@ -6,7 +6,7 @@ class ExerciseCollectionsController < ApplicationController
before_action :set_exercise_collection, only: %i[show edit update destroy statistics]
def index
@exercise_collections = ExerciseCollection.all.paginate(page: params[:page])
@exercise_collections = ExerciseCollection.all.paginate(page: params[:page], per_page: per_page_param)
authorize!
end

View File

@ -11,21 +11,21 @@ class ExercisesController < ApplicationController
before_action :set_execution_environments, only: %i[index create edit new update]
before_action :set_exercise_and_authorize,
only: MEMBER_ACTIONS + %i[clone implement working_times intervention search run statistics submit reload feedback
requests_for_comments study_group_dashboard export_external_check export_external_confirm]
before_action :set_external_user_and_authorize, only: [:statistics]
requests_for_comments study_group_dashboard export_external_check export_external_confirm
external_user_statistics]
before_action :set_external_user_and_authorize, only: [:external_user_statistics]
before_action :set_file_types, only: %i[create edit new update]
before_action :set_course_token, only: [:implement]
before_action :set_available_tips, only: %i[implement show new edit]
skip_before_action :verify_authenticity_token,
only: %i[import_exercise import_uuid_check export_external_confirm export_external_check]
skip_after_action :verify_authorized, only: %i[import_exercise import_uuid_check export_external_confirm]
skip_after_action :verify_policy_scoped, only: %i[import_exercise import_uuid_check export_external_confirm],
raise: false
skip_before_action :verify_authenticity_token, only: %i[import_exercise import_uuid_check]
skip_after_action :verify_authorized, only: %i[import_exercise import_uuid_check]
skip_after_action :verify_policy_scoped, only: %i[import_exercise import_uuid_check], raise: false
def authorize!
authorize(@exercise || @exercises)
end
private :authorize!
def max_intervention_count_per_day
@ -51,7 +51,7 @@ raise: false
exercise = @exercise.duplicate(public: false, token: nil, user: current_user)
exercise.send(:generate_token)
if exercise.save
redirect_to(exercise, notice: t('shared.object_cloned', model: Exercise.model_name.human))
redirect_to(exercise_path(exercise), notice: t('shared.object_cloned', model: Exercise.model_name.human))
else
flash[:danger] = t('shared.message_failure')
redirect_to(@exercise)
@ -67,6 +67,7 @@ raise: false
end
subpaths.flatten.uniq
end
private :collect_paths
def create
@ -103,7 +104,7 @@ raise: false
def feedback
authorize!
@feedbacks = @exercise.user_exercise_feedbacks.paginate(page: params[:page])
@feedbacks = @exercise.user_exercise_feedbacks.paginate(page: params[:page], per_page: per_page_param)
@submissions = @feedbacks.map do |feedback|
feedback.exercise.final_submission(feedback.user)
end
@ -128,6 +129,7 @@ raise: false
end
def export_external_confirm
authorize!
@exercise.uuid = SecureRandom.uuid if @exercise.uuid.nil?
error = ExerciseService::PushExternal.call(
@ -176,7 +178,7 @@ raise: false
ActiveRecord::Base.transaction do
exercise = ::ProformaService::Import.call(zip: tempfile, user: user)
exercise.save!
return render json: {}, status: :created
render json: {}, status: :created
end
rescue Proforma::ExerciseNotOwned
render json: {}, status: :unauthorized
@ -192,12 +194,14 @@ raise: false
api_key = authorization_header&.split(' ')&.second
user_by_codeharbor_token(api_key)
end
private :user_from_api_key
def user_by_codeharbor_token(api_key)
link = CodeharborLink.find_by(api_key: api_key)
link&.user
end
private :user_by_codeharbor_token
def exercise_params
@ -225,6 +229,7 @@ raise: false
)
end
end
private :exercise_params
def handle_file_uploads
@ -241,6 +246,7 @@ raise: false
end
end
end
private :handle_file_uploads
def handle_exercise_tips
@ -258,6 +264,7 @@ raise: false
redirect_to(edit_exercise_path(@exercise))
end
end
private :handle_exercise_tips
def update_exercise_tips(exercise_tips, parent_exercise_tip_id, rank)
@ -283,6 +290,7 @@ raise: false
end
result
end
private :update_exercise_tips
def implement
@ -348,6 +356,7 @@ raise: false
@course_token = '702cbd2a-c84c-4b37-923a-692d7d1532d0'
end
end
private :set_course_token
def set_available_tips
@ -374,13 +383,14 @@ raise: false
# Return an array with top-level tips
@tips = nested_tips.values.select {|tip| tip.parent_exercise_tip_id.nil? }
end
private :set_available_tips
def working_times
working_time_accumulated = @exercise.accumulated_working_time_for_only(current_user)
working_time_75_percentile = @exercise.get_quantiles([0.75]).first
render(json: {working_time_75_percentile: working_time_75_percentile,
working_time_accumulated: working_time_accumulated})
working_time_accumulated: working_time_accumulated})
end
def intervention
@ -401,7 +411,8 @@ working_time_accumulated: working_time_accumulated})
search_text = params[:search_text]
search = Search.new(user: current_user, exercise: @exercise, search: search_text)
begin search.save
begin
search.save
render(json: {success: 'true'})
rescue StandardError
render(json: {success: 'false', error: "could not save search: #{$ERROR_INFO}"})
@ -410,7 +421,7 @@ working_time_accumulated: working_time_accumulated})
def index
@search = policy_scope(Exercise).ransack(params[:q])
@exercises = @search.result.includes(:execution_environment, :user).order(:title).paginate(page: params[:page])
@exercises = @search.result.includes(:execution_environment, :user).order(:title).paginate(page: params[:page], per_page: per_page_param)
authorize!
end
@ -424,12 +435,14 @@ working_time_accumulated: working_time_accumulated})
def set_execution_environments
@execution_environments = ExecutionEnvironment.all.order(:name)
end
private :set_execution_environments
def set_exercise_and_authorize
@exercise = Exercise.find(params[:id])
authorize!
end
private :set_exercise_and_authorize
def set_external_user_and_authorize
@ -438,16 +451,17 @@ working_time_accumulated: working_time_accumulated})
authorize!
end
end
private :set_external_user_and_authorize
def set_file_types
@file_types = FileType.all.order(:name)
end
private :set_file_types
def collect_set_and_unset_exercise_tags
@search = policy_scope(Tag).ransack(params[:q])
@tags = @search.result.order(:name)
@tags = policy_scope(Tag)
checked_exercise_tags = @exercise.exercise_tags
checked_tags = checked_exercise_tags.collect(&:tag).to_set
unchecked_tags = Tag.all.to_set.subtract checked_tags
@ -455,6 +469,7 @@ working_time_accumulated: working_time_accumulated})
ExerciseTag.new(exercise: @exercise, tag: tag)
end
end
private :collect_set_and_unset_exercise_tags
def show
@ -466,15 +481,43 @@ working_time_accumulated: working_time_accumulated})
end
def statistics
if @external_user
# Show general statistic page for specific exercise
user_statistics = {'InternalUser' => {}, 'ExternalUser' => {}}
query = Submission.select('user_id, user_type, MAX(score) AS maximum_score, COUNT(id) AS runs')
.where(exercise_id: @exercise.id)
.group('user_id, user_type')
query = if policy(@exercise).detailed_statistics?
query
elsif !policy(@exercise).detailed_statistics? && current_user.study_groups.count.positive?
query.where(study_groups: current_user.study_groups.pluck(:id), cause: 'submit')
else
# e.g. internal user without any study groups, show no submissions
query.where('false')
end
query.each do |tuple|
user_statistics[tuple['user_type']][tuple['user_id'].to_i] = tuple
end
render locals: {
user_statistics: user_statistics,
}
end
def external_user_statistics
# Render statistics page for one specific external user
authorize(@external_user, :statistics?)
if policy(@exercise).detailed_statistics?
@submissions = Submission.where(user: @external_user,
exercise_id: @exercise.id).in_study_group_of(current_user).order('created_at')
submissions = Submission.where(user: @external_user, exercise: @exercise)
.in_study_group_of(current_user)
.order('created_at')
@show_autosaves = params[:show_autosaves] == 'true' || submissions.none? {|s| s.cause != 'autosave' }
submissions = submissions.where.not(cause: 'autosave') unless @show_autosaves
interventions = UserExerciseIntervention.where('user_id = ? AND exercise_id = ?', @external_user.id,
@exercise.id)
@all_events = (@submissions + interventions).sort_by(&:created_at)
@all_events = (submissions + interventions).sort_by(&:created_at)
@deltas = @all_events.map.with_index do |item, index|
delta = item.created_at - @all_events[index - 1].created_at if index.positive?
delta.nil? || (delta > StatisticsHelper::WORKING_TIME_DELTA_IN_SECONDS) ? 0 : delta
@ -486,35 +529,15 @@ working_time_accumulated: working_time_accumulated})
else
final_submissions = Submission.where(user: @external_user,
exercise_id: @exercise.id).in_study_group_of(current_user).final
@submissions = []
submissions = []
%i[before_deadline within_grace_period after_late_deadline].each do |filter|
relevant_submission = final_submissions.send(filter).latest
@submissions.push relevant_submission if relevant_submission.present?
submissions.push relevant_submission if relevant_submission.present?
end
@all_events = @submissions
@all_events = submissions
end
render 'exercises/external_users/statistics'
else
# Show general statistic page for specific exercise
user_statistics = {}
additional_filter = if policy(@exercise).detailed_statistics?
''
elsif !policy(@exercise).detailed_statistics? && current_user.study_groups.count.positive?
"AND study_group_id IN (#{current_user.study_groups.pluck(:id).join(', ')}) AND cause = 'submit'"
else
# e.g. internal user without any study groups, show no submissions
'AND FALSE'
end
query = "SELECT user_id, MAX(score) AS maximum_score, COUNT(id) AS runs
FROM submissions WHERE exercise_id = #{@exercise.id} #{additional_filter} GROUP BY
user_id;"
ApplicationRecord.connection.execute(query).each do |tuple|
user_statistics[tuple['user_id'].to_i] = tuple
end
render locals: {
user_statistics: user_statistics,
}
end
end
def submit
@ -534,8 +557,6 @@ working_time_accumulated: working_time_accumulated})
end
def transmit_lti_score
::NewRelic::Agent.add_custom_attributes({submission: @submission.id,
normalized_score: @submission.normalized_score})
response = send_score(@submission)
if response[:status] == 'success'
@ -552,6 +573,7 @@ working_time_accumulated: working_time_accumulated})
end
end
end
private :transmit_lti_score
def update

View File

@ -10,7 +10,7 @@ class ExternalUsersController < ApplicationController
def index
@search = ExternalUser.ransack(params[:q])
@users = @search.result.in_study_group_of(current_user).includes(:consumer).paginate(page: params[:page])
@users = @search.result.in_study_group_of(current_user).includes(:consumer).paginate(page: params[:page], per_page: per_page_param)
authorize!
end
@ -32,7 +32,7 @@ class ExternalUsersController < ApplicationController
score,
id,
CASE
WHEN working_time >= #{StatisticsHelper::WORKING_TIME_DELTA_IN_SQL_INTERVAL} THEN '0'
WHEN #{StatisticsHelper.working_time_larger_delta} THEN '0'
ELSE working_time
END AS working_time_new
FROM

View File

@ -19,7 +19,7 @@ class FileTemplatesController < ApplicationController
# GET /file_templates
# GET /file_templates.json
def index
@file_templates = FileTemplate.all.order(:file_type_id).paginate(page: params[:page])
@file_templates = FileTemplate.all.order(:file_type_id).paginate(page: params[:page], per_page: per_page_param)
authorize!
end
@ -48,7 +48,7 @@ class FileTemplatesController < ApplicationController
respond_to do |format|
if @file_template.save
format.html { redirect_to @file_template, notice: 'File template was successfully created.' }
format.html { redirect_to @file_template, notice: t('shared.object_created', model: @file_template.class.model_name.human) }
format.json { render :show, status: :created, location: @file_template }
else
format.html { render :new }
@ -63,7 +63,7 @@ class FileTemplatesController < ApplicationController
authorize!
respond_to do |format|
if @file_template.update(file_template_params)
format.html { redirect_to @file_template, notice: 'File template was successfully updated.' }
format.html { redirect_to @file_template, notice: t('shared.object_updated', model: @file_template.class.model_name.human) }
format.json { render :show, status: :ok, location: @file_template }
else
format.html { render :edit }
@ -78,7 +78,7 @@ class FileTemplatesController < ApplicationController
authorize!
@file_template.destroy
respond_to do |format|
format.html { redirect_to file_templates_url, notice: 'File template was successfully destroyed.' }
format.html { redirect_to file_templates_url, notice: t('shared.object_destroyed', model: @file_template.class.model_name.human) }
format.json { head :no_content }
end
end

View File

@ -33,7 +33,7 @@ class FileTypesController < ApplicationController
private :file_type_params
def index
@file_types = FileType.all.includes(:user).order(:name).paginate(page: params[:page])
@file_types = FileType.all.includes(:user).order(:name).paginate(page: params[:page], per_page: per_page_param)
authorize!
end

View File

@ -6,7 +6,7 @@ class FlowrController < ApplicationController
# get the latest submission for this user that also has a test run (i.e. structured_errors if applicable)
submission = Submission.joins(:testruns)
.where(submissions: {user_id: current_user.id, user_type: current_user.class.name})
.order('testruns.created_at DESC').first
.merge(Testrun.order(created_at: :desc)).first
# Return if no submission was found
if submission.blank? || @embed_options[:disable_hints] || @embed_options[:hide_test_results]

View File

@ -6,7 +6,6 @@ class InternalUsersController < ApplicationController
before_action :require_activation_token, only: :activate
before_action :require_reset_password_token, only: :reset_password
before_action :set_user, only: MEMBER_ACTIONS
skip_before_action :verify_authenticity_token, only: :activate
after_action :verify_authorized, except: %i[activate forgot_password reset_password]
def activate
@ -33,9 +32,15 @@ class InternalUsersController < ApplicationController
def create
@user = InternalUser.new(internal_user_params)
@user.role = role_param if current_user.admin?
authorize!
@user.send(:setup_activation)
create_and_respond(object: @user) { @user.send(:send_activation_needed_email!) }
create_and_respond(object: @user) do
@user.send(:send_activation_needed_email!)
# The return value is used as a flash message. If this block does not
# have any specific return value, a default success message is shown.
nil
end
end
def deliver_reset_password_instructions
@ -63,15 +68,20 @@ class InternalUsersController < ApplicationController
def index
@search = InternalUser.ransack(params[:q])
@users = @search.result.includes(:consumer).order(:name).paginate(page: params[:page])
@users = @search.result.includes(:consumer).order(:name).paginate(page: params[:page], per_page: per_page_param)
authorize!
end
def internal_user_params
params[:internal_user].permit(:consumer_id, :email, :name, :role) if params[:internal_user].present?
params.require(:internal_user).permit(:consumer_id, :email, :name)
end
private :internal_user_params
def role_param
params.require(:internal_user).permit(:role)[:role]
end
private :role_param
def new
@user = InternalUser.new
authorize!
@ -129,6 +139,7 @@ class InternalUsersController < ApplicationController
# the form by another user. Otherwise, the update might fail if an
# activation_token or password_reset_token is present
@user.validate_password = current_user == @user
@user.role = role_param if current_user.admin?
update_and_respond(object: @user, params: internal_user_params)
end

View File

@ -15,7 +15,7 @@ class ProxyExercisesController < ApplicationController
user: current_user)
proxy_exercise.send(:generate_token)
if proxy_exercise.save
redirect_to(proxy_exercise, notice: t('shared.object_cloned', model: ProxyExercise.model_name.human))
redirect_to(proxy_exercise_path(proxy_exercise), notice: t('shared.object_cloned', model: ProxyExercise.model_name.human))
else
flash[:danger] = t('shared.message_failure')
redirect_to(@proxy_exercise)
@ -51,7 +51,7 @@ class ProxyExercisesController < ApplicationController
def index
@search = policy_scope(ProxyExercise).ransack(params[:q])
@proxy_exercises = @search.result.order(:title).paginate(page: params[:page])
@proxy_exercises = @search.result.order(:title).paginate(page: params[:page], per_page: per_page_param)
authorize!
end

View File

@ -39,8 +39,8 @@ class RemoteEvaluationController < ApplicationController
else
{
message: "Your submission was successfully scored with #{@submission.normalized_score}%. " \
'However, your score could not be sent to the e-Learning platform. Please reopen ' \
'the exercise through the e-Learning platform and try again.',
'However, your score could not be sent to the e-Learning platform. Please check ' \
'the submission deadline, reopen the exercise through the e-Learning platform and try again.',
status: 410,
}
end

View File

@ -1,8 +1,9 @@
# frozen_string_literal: true
class RequestForCommentsController < ApplicationController
include CommonBehavior
before_action :require_user!
before_action :set_request_for_comment, only: %i[show mark_as_solved set_thank_you_note]
before_action :set_request_for_comment, only: %i[show mark_as_solved set_thank_you_note clear_question]
before_action :set_study_group_grouping,
only: %i[index my_comment_requests rfcs_with_my_comments rfcs_for_exercise]
@ -23,7 +24,7 @@ class RequestForCommentsController < ApplicationController
.where(exercises: {unpublished: false})
.includes(submission: [:study_group])
.order('created_at DESC')
.paginate(page: params[:page], total_entries: @search.result.length)
.paginate(page: params[:page], per_page: per_page_param, total_entries: @search.result.length)
authorize!
end
@ -36,7 +37,7 @@ class RequestForCommentsController < ApplicationController
.ransack(params[:q])
@request_for_comments = @search.result
.order('created_at DESC')
.paginate(page: params[:page])
.paginate(page: params[:page], per_page: per_page_param)
authorize!
render 'index'
end
@ -50,7 +51,7 @@ class RequestForCommentsController < ApplicationController
.ransack(params[:q])
@request_for_comments = @search.result
.order('last_comment DESC')
.paginate(page: params[:page])
.paginate(page: params[:page], per_page: per_page_param)
authorize!
render 'index'
end
@ -65,7 +66,7 @@ class RequestForCommentsController < ApplicationController
@request_for_comments = @search.result
.joins(:exercise)
.order('last_comment DESC')
.paginate(page: params[:page])
.paginate(page: params[:page], per_page: per_page_param)
# let the exercise decide, whether its rfcs should be visible
authorize(exercise)
render 'index'
@ -101,6 +102,12 @@ class RequestForCommentsController < ApplicationController
end
end
# POST /request_for_comments/1/clear_question
def clear_question
authorize!
update_and_respond(object: @request_for_comment, params: {question: nil})
end
# GET /request_for_comments/1
# GET /request_for_comments/1.json
def show

View File

@ -24,7 +24,7 @@ class SessionsController < ApplicationController
store_lti_session_data(consumer: @consumer, parameters: params)
store_nonce(params[:oauth_nonce])
if params[:custom_redirect_target]
redirect_to(params[:custom_redirect_target])
redirect_to(URI.parse(params[:custom_redirect_target].to_s).path)
else
redirect_to(implement_exercise_path(@exercise),
notice: t("sessions.create_through_lti.session_#{lti_outcome_service?(@exercise.id, @current_user.id) ? 'with' : 'without'}_outcome",
@ -43,6 +43,10 @@ class SessionsController < ApplicationController
def destroy_through_lti
@submission = Submission.find(params[:submission_id])
authorize(@submission, :show?)
lti_parameter = LtiParameter.where(external_users_id: @submission.user_id, exercises_id: @submission.exercise_id).last
@url = consumer_return_url(build_tool_provider(consumer: @submission.user.consumer, parameters: lti_parameter&.lti_parameters))
clear_lti_session_data(@submission.exercise_id, @submission.user_id)
end

View File

@ -7,7 +7,7 @@ class StudyGroupsController < ApplicationController
def index
@search = policy_scope(StudyGroup).ransack(params[:q])
@study_groups = @search.result.includes(:consumer).order(:name).paginate(page: params[:page])
@study_groups = @search.result.includes(:consumer).order(:name).paginate(page: params[:page], per_page: per_page_param)
authorize!
end

View File

@ -9,10 +9,10 @@ class SubmissionsController < ApplicationController
before_action :require_user!
before_action :set_submission, only: %i[download download_file render_file run score show statistics test]
before_action :set_testrun, only: %i[run score test]
before_action :set_files, only: %i[download show]
before_action :set_files_and_specific_file, only: %i[download_file render_file run test]
before_action :set_mime_type, only: %i[download_file render_file]
skip_before_action :verify_authenticity_token, only: %i[download_file render_file]
def create
@submission = Submission.new(submission_params)
@ -27,8 +27,8 @@ class SubmissionsController < ApplicationController
stringio = Zip::OutputStream.write_buffer do |zio|
@files.each do |file|
zio.put_next_entry(file.filepath)
zio.write(file.content.presence || file.native_file.read)
zio.put_next_entry(file.filepath.delete_prefix('/'))
zio.write(file.read)
end
# zip exercise description
@ -39,7 +39,7 @@ class SubmissionsController < ApplicationController
# zip .co file
zio.put_next_entry('.co')
zio.write(File.read(id_file))
File.delete(id_file) if File.exist?(id_file)
FileUtils.rm_rf(id_file)
# zip client scripts
scripts_path = 'app/assets/remote_scripts'
@ -56,22 +56,18 @@ class SubmissionsController < ApplicationController
def download_file
raise Pundit::NotAuthorizedError if @embed_options[:disable_download]
if @file.native_file?
send_file(@file.native_file.path)
else
send_data(@file.content, filename: @file.name_with_extension)
end
send_data(@file.read, filename: @file.name_with_extension)
end
def index
@search = Submission.ransack(params[:q])
@submissions = @search.result.includes(:exercise, :user).paginate(page: params[:page])
@submissions = @search.result.includes(:exercise, :user).paginate(page: params[:page], per_page: per_page_param)
authorize!
end
def render_file
if @file.native_file?
send_file(@file.native_file.path, disposition: 'inline')
send_data(@file.read, filename: @file.name_with_extension, disposition: 'inline')
else
render(plain: @file.content)
end
@ -85,10 +81,14 @@ class SubmissionsController < ApplicationController
hijack do |tubesock|
client_socket = tubesock
return kill_client_socket(client_socket) if @embed_options[:disable_run]
client_socket.onopen do |_event|
kill_client_socket(client_socket) if @embed_options[:disable_run]
end
client_socket.onclose do |_event|
runner_socket&.close(:terminated_by_client)
@testrun[:status] ||= :terminated_by_client
end
client_socket.onmessage do |raw_event|
@ -97,9 +97,17 @@ class SubmissionsController < ApplicationController
# Otherwise, we expect to receive a JSON: Parsing.
event = JSON.parse(raw_event).deep_symbolize_keys
event[:cmd] = event[:cmd].to_sym
event[:stream] = event[:stream].to_sym if event.key? :stream
case event[:cmd].to_sym
# We could store the received event. However, it is also echoed by the container
# and correctly identified as the original input. Therefore, we don't store
# it here to prevent duplicated events.
# @testrun[:messages].push(event)
case event[:cmd]
when :client_kill
@testrun[:status] = :terminated_by_client
close_client_connection(client_socket)
Rails.logger.debug('Client exited container.')
when :result, :canvasevent, :exception
@ -125,68 +133,88 @@ class SubmissionsController < ApplicationController
end
end
@output = +''
durations = @submission.run(@file) do |socket|
@testrun[:output] = +''
durations = @submission.run(@file) do |socket, starting_time|
runner_socket = socket
@testrun[:starting_time] = starting_time
client_socket.send_data JSON.dump({cmd: :status, status: :container_running})
runner_socket.on :stdout do |data|
json_data = prepare data, :stdout
@output << json_data[0, max_output_buffer_size - @output.size]
client_socket.send_data(json_data)
message = retrieve_message_from_output data, :stdout
@testrun[:output] << message[:data][0, max_output_buffer_size - @testrun[:output].size] if message[:data]
send_and_store client_socket, message
end
runner_socket.on :stderr do |data|
json_data = prepare data, :stderr
@output << json_data[0, max_output_buffer_size - @output.size]
client_socket.send_data(json_data)
message = retrieve_message_from_output data, :stderr
@testrun[:output] << message[:data][0, max_output_buffer_size - @testrun[:output].size] if message[:data]
send_and_store client_socket, message
end
runner_socket.on :exit do |exit_code|
@testrun[:exit_code] = exit_code
exit_statement =
if @output.empty? && exit_code.zero?
if @testrun[:output].empty? && exit_code.zero?
@testrun[:status] = :ok
t('exercises.implement.no_output_exit_successful', timestamp: l(Time.zone.now, format: :short), exit_code: exit_code)
elsif @output.empty?
elsif @testrun[:output].empty?
@testrun[:status] = :failed
t('exercises.implement.no_output_exit_failure', timestamp: l(Time.zone.now, format: :short), exit_code: exit_code)
elsif exit_code.zero?
@testrun[:status] = :ok
"\n#{t('exercises.implement.exit_successful', timestamp: l(Time.zone.now, format: :short), exit_code: exit_code)}"
else
@testrun[:status] = :failed
"\n#{t('exercises.implement.exit_failure', timestamp: l(Time.zone.now, format: :short), exit_code: exit_code)}"
end
client_socket.send_data JSON.dump({cmd: :write, stream: :stdout, data: "#{exit_statement}\n"})
send_and_store client_socket, {cmd: :write, stream: :stdout, data: "#{exit_statement}\n"}
if exit_code == 137
send_and_store client_socket, {cmd: :status, status: :out_of_memory}
@testrun[:status] = :out_of_memory
end
close_client_connection(client_socket)
end
end
@container_execution_time = durations[:execution_duration]
@waiting_for_container_time = durations[:waiting_duration]
@testrun[:container_execution_time] = durations[:execution_duration]
@testrun[:waiting_for_container_time] = durations[:waiting_duration]
rescue Runner::Error::ExecutionTimeout => e
client_socket.send_data JSON.dump({cmd: :status, status: :timeout})
send_and_store client_socket, {cmd: :status, status: :timeout}
close_client_connection(client_socket)
Rails.logger.debug { "Running a submission timed out: #{e.message}" }
@output = "timeout: #{@output}"
@testrun[:status] ||= :timeout
@testrun[:output] = "timeout: #{@testrun[:output]}"
extract_durations(e)
rescue Runner::Error => e
client_socket.send_data JSON.dump({cmd: :status, status: :container_depleted})
send_and_store client_socket, {cmd: :status, status: :container_depleted}
close_client_connection(client_socket)
@testrun[:status] ||= :container_depleted
Rails.logger.debug { "Runner error while running a submission: #{e.message}" }
extract_durations(e)
ensure
save_run_output
save_testrun_output 'run'
end
def score
hijack do |tubesock|
return if @embed_options[:disable_score]
tubesock.onopen do |_event|
switch_locale do
kill_client_socket(tubesock) if @embed_options[:disable_score]
# The score is stored separately, we can forward it to the client immediately
tubesock.send_data(JSON.dump(@submission.calculate_score))
# To enable hints when scoring a submission, uncomment the next line:
# send_hints(tubesock, StructuredError.where(submission: @submission))
rescue Runner::Error => e
tubesock.send_data JSON.dump({cmd: :status, status: :container_depleted})
Rails.logger.debug { "Runner error while scoring submission #{@submission.id}: #{e.message}" }
ensure
kill_client_socket(tubesock)
rescue Runner::Error => e
extract_durations(e)
send_and_store tubesock, {cmd: :status, status: :container_depleted}
kill_client_socket(tubesock)
Rails.logger.debug { "Runner error while scoring submission #{@submission.id}: #{e.message}" }
@testrun[:passed] = false
save_testrun_output 'assess'
end
end
end
end
@ -196,14 +224,22 @@ class SubmissionsController < ApplicationController
def test
hijack do |tubesock|
return kill_client_socket(tubesock) if @embed_options[:disable_run]
tubesock.onopen do |_event|
switch_locale do
kill_client_socket(tubesock) if @embed_options[:disable_run]
# The score is stored separately, we can forward it to the client immediately
tubesock.send_data(JSON.dump(@submission.test(@file)))
rescue Runner::Error => e
tubesock.send_data JSON.dump({cmd: :status, status: :container_depleted})
Rails.logger.debug { "Runner error while testing submission #{@submission.id}: #{e.message}" }
ensure
kill_client_socket(tubesock)
rescue Runner::Error => e
extract_durations(e)
send_and_store tubesock, {cmd: :status, status: :container_depleted}
kill_client_socket(tubesock)
Rails.logger.debug { "Runner error while testing submission #{@submission.id}: #{e.message}" }
@testrun[:passed] = false
save_testrun_output 'assess'
end
end
end
end
@ -221,6 +257,7 @@ class SubmissionsController < ApplicationController
end
def kill_client_socket(client_socket)
# We don't want to store this (arbitrary) exit command and redirect it ourselves
client_socket.send_data JSON.dump({cmd: :exit})
client_socket.close
end
@ -240,7 +277,7 @@ class SubmissionsController < ApplicationController
# parse validation token
content = "#{remote_evaluation_mapping.validation_token}\n"
# parse remote request url
content += "#{request.base_url}/evaluate\n"
content += "#{evaluate_url}\n"
@submission.files.each do |file|
content += "#{file.filepath}=#{file.file_id}\n"
end
@ -249,21 +286,33 @@ class SubmissionsController < ApplicationController
end
def extract_durations(error)
@container_execution_time = error.execution_duration
@waiting_for_container_time = error.waiting_duration
@testrun[:starting_time] = error.starting_time
@testrun[:container_execution_time] = error.execution_duration
@testrun[:waiting_for_container_time] = error.waiting_duration
end
def extract_errors
results = []
if @output.present?
if @testrun[:output].present?
@submission.exercise.execution_environment.error_templates.each do |template|
pattern = Regexp.new(template.signature).freeze
results << StructuredError.create_from_template(template, @output, @submission) if pattern.match(@output)
results << StructuredError.create_from_template(template, @testrun[:output], @submission) if pattern.match(@testrun[:output])
end
end
results
end
def send_and_store(client_socket, message)
message[:timestamp] = if @testrun[:starting_time]
ActiveSupport::Duration.build(Time.zone.now - @testrun[:starting_time])
else
0.seconds
end
@testrun[:messages].push message
@testrun[:status] = message[:status] if message[:status]
client_socket.send_data JSON.dump(message)
end
def max_output_buffer_size
if @submission.cause == 'requestComments'
5000
@ -272,28 +321,25 @@ class SubmissionsController < ApplicationController
end
end
def prepare(data, stream)
if valid_command? data
data
else
JSON.dump({cmd: :write, stream: stream, data: data})
end
end
def sanitize_filename
params[:filename].gsub(/\.json$/, '')
end
# save the output of this "run" as a "testrun" (scoring runs are saved in submission.rb)
def save_run_output
Testrun.create(
def save_testrun_output(cause)
testrun = Testrun.create!(
file: @file,
cause: 'run',
passed: @testrun[:passed],
cause: cause,
submission: @submission,
output: @output,
container_execution_time: @container_execution_time,
waiting_for_container_time: @waiting_for_container_time
exit_code: @testrun[:exit_code], # might be nil, e.g., when the run did not finish
status: @testrun[:status],
output: @testrun[:output].presence, # TODO: Remove duplicated saving of the output after creating TestrunMessages
container_execution_time: @testrun[:container_execution_time],
waiting_for_container_time: @testrun[:waiting_for_container_time]
)
TestrunMessage.create_for(testrun, @testrun[:messages])
TestrunExecutionEnvironment.create(testrun: testrun, execution_environment: @submission.used_execution_environment)
end
def send_hints(tubesock, errors)
@ -301,7 +347,7 @@ class SubmissionsController < ApplicationController
errors = errors.to_a.uniq(&:hint)
errors.each do |error|
tubesock.send_data JSON.dump({cmd: 'hint', hint: error.hint, description: error.error_template.description})
send_and_store tubesock, {cmd: :hint, hint: error.hint, description: error.error_template.description}
end
end
@ -327,10 +373,26 @@ class SubmissionsController < ApplicationController
authorize!
end
def valid_command?(data)
def set_testrun
@testrun = {
messages: [],
exit_code: nil,
status: nil,
}
end
def retrieve_message_from_output(data, stream)
parsed = JSON.parse(data)
parsed.instance_of?(Hash) && parsed.key?('cmd')
if parsed.instance_of?(Hash) && parsed.key?('cmd')
parsed.symbolize_keys!
# Symbolize two values if present
parsed[:cmd] = parsed[:cmd].to_sym
parsed[:stream] = parsed[:stream].to_sym if parsed.key? :stream
parsed
else
{cmd: :write, stream: stream, data: data}
end
rescue JSON::ParserError
false
{cmd: :write, stream: stream, data: data}
end
end

View File

@ -28,7 +28,7 @@ class TagsController < ApplicationController
private :tag_params
def index
@tags = Tag.all.paginate(page: params[:page])
@tags = Tag.all.paginate(page: params[:page], per_page: per_page_param)
authorize!
end

View File

@ -34,7 +34,7 @@ class TipsController < ApplicationController
private :tip_params
def index
@tips = Tip.all.paginate(page: params[:page])
@tips = Tip.all.paginate(page: params[:page], per_page: per_page_param)
authorize!
end

View File

@ -2,7 +2,7 @@
class Runner
class Error < ApplicationError
attr_accessor :waiting_duration, :execution_duration
attr_accessor :waiting_duration, :execution_duration, :starting_time
class BadRequest < Error; end

View File

@ -18,11 +18,11 @@ module ApplicationHelper
end
def empty
tag.i(nil, class: 'empty fa fa-minus')
tag.i(nil, class: 'empty fa-solid fa-minus')
end
def label_column(label)
tag.div(class: 'col-sm-3') do
tag.div(class: 'col-md-3') do
tag.strong do
I18n.translation_present?("activerecord.attributes.#{label}") ? t("activerecord.attributes.#{label}") : t(label)
end
@ -31,13 +31,21 @@ module ApplicationHelper
private :label_column
def no
tag.i(nil, class: 'fa fa-times')
tag.i(nil, class: 'fa-solid fa-xmark')
end
def per_page_param
if params[:per_page]
[params[:per_page].to_i, 100].min
else
WillPaginate.per_page
end
end
def progress_bar(value)
tag.div(class: value ? 'progress' : 'disabled progress') do
tag.div(value ? "#{value}%" : '', 'aria-valuemax': 100, 'aria-valuemin': 0,
'aria-valuenow': value, class: 'progress-bar progress-bar-striped', role: 'progressbar', style: "width: #{[value || 0, 100].min}%;")
'aria-valuenow': value, class: 'progress-bar progress-bar-striped', role: 'progressbar', style: "width: #{[value || 0, 100].min}%;")
end
end
@ -64,13 +72,13 @@ module ApplicationHelper
end
def value_column(value)
tag.div(class: 'col-sm-9') do
tag.div(class: 'col-md-9') do
block_given? ? yield : symbol_for(value)
end
end
private :value_column
def yes
tag.i(nil, class: 'fa fa-check')
tag.i(nil, class: 'fa-solid fa-check')
end
end

View File

@ -2,7 +2,9 @@
module StatisticsHelper
WORKING_TIME_DELTA_IN_SECONDS = 5.minutes
WORKING_TIME_DELTA_IN_SQL_INTERVAL = "'0:05:00'" # yes, a string with quotes
def self.working_time_larger_delta
@working_time_larger_delta ||= ActiveRecord::Base.sanitize_sql(['working_time >= ?', '0:05:00'])
end
def statistics_data
[
@ -79,7 +81,7 @@ module StatisticsHelper
{
key: 'container_requests_per_minute',
name: t('statistics.entries.exercises.container_requests_per_minute'),
data: (Testrun.where('created_at >= ?', DateTime.now - 1.hour).count.to_f / 60).round(2),
data: (Testrun.where(created_at: DateTime.now - 1.hour..).count.to_f / 60).round(2),
unit: '/min',
},
{
@ -179,7 +181,7 @@ module StatisticsHelper
key: 'rfcs',
name: t('activerecord.models.request_for_comment.other'),
data: RequestForComment.in_range(from, to)
.select("date_trunc('#{interval}', created_at) AS \"key\", count(id) AS \"value\"")
.select(RequestForComment.sanitize_sql(['date_trunc(?, created_at) AS "key", count(id) AS "value"', interval]))
.group('key').order('key'),
},
{
@ -187,7 +189,7 @@ module StatisticsHelper
name: t('statistics.entries.request_for_comments.percent_solved'),
data: RequestForComment.in_range(from, to)
.where(solved: true)
.select("date_trunc('#{interval}', created_at) AS \"key\", count(id) AS \"value\"")
.select(RequestForComment.sanitize_sql(['date_trunc(?, created_at) AS "key", count(id) AS "value"', interval]))
.group('key').order('key'),
},
{
@ -195,14 +197,14 @@ module StatisticsHelper
name: t('statistics.entries.request_for_comments.percent_soft_solved'),
data: RequestForComment.in_range(from, to).unsolved
.where(full_score_reached: true)
.select("date_trunc('#{interval}', created_at) AS \"key\", count(id) AS \"value\"")
.select(RequestForComment.sanitize_sql(['date_trunc(?, created_at) AS "key", count(id) AS "value"', interval]))
.group('key').order('key'),
},
{
key: 'rfcs_unsolved',
name: t('statistics.entries.request_for_comments.percent_unsolved'),
data: RequestForComment.in_range(from, to).unsolved
.select("date_trunc('#{interval}', created_at) AS \"key\", count(id) AS \"value\"")
.select(RequestForComment.sanitize_sql(['date_trunc(?, created_at) AS "key", count(id) AS "value"', interval]))
.group('key').order('key'),
},
]
@ -215,14 +217,14 @@ module StatisticsHelper
name: t('statistics.entries.users.active'),
data: ExternalUser.joins(:submissions)
.where(submissions: {created_at: from..to})
.select("date_trunc('#{interval}', submissions.created_at) AS \"key\", count(distinct external_users.id) AS \"value\"")
.select(ExternalUser.sanitize_sql(['date_trunc(?, submissions.created_at) AS "key", count(distinct external_users.id) AS "value"', interval]))
.group('key').order('key'),
},
{
key: 'submissions',
name: t('statistics.entries.exercises.submissions'),
data: Submission.where(created_at: from..to)
.select("date_trunc('#{interval}', created_at) AS \"key\", count(id) AS \"value\"")
.select(Submission.sanitize_sql(['date_trunc(?, created_at) AS "key", count(id) AS "value"', interval]))
.group('key').order('key'),
axis: 'right',
},

View File

@ -10,12 +10,14 @@
// JS
import 'jquery';
import 'jquery-ujs'
import 'bootstrap/dist/js/bootstrap.bundle.min';
import * as bootstrap from 'bootstrap/dist/js/bootstrap.bundle';
import 'chosen-js/chosen.jquery';
import 'jstree';
import 'underscore';
import 'd3';
import '@sentry/browser';
import 'sorttable';
window.bootstrap = bootstrap; // Publish bootstrap in global namespace
window._ = _; // Publish underscore's `_` in global namespace
window.d3 = d3; // Publish d3 in global namespace
window.Sentry = Sentry; // Publish sentry in global namespace
@ -40,6 +42,23 @@ import 'jquery-ui/themes/base/resizable.css'
import 'jquery-ui/themes/base/selectable.css'
import 'jquery-ui/themes/base/sortable.css'
// I18n locales
import { I18n } from "i18n-js";
import locales from "../../tmp/locales.json";
// Fetch user locale from html#lang.
// This value is being set on `app/views/layouts/application.html.erb` and
// is inferred from `ACCEPT-LANGUAGE` header.
const userLocale = document.documentElement.lang;
export const i18n = new I18n();
i18n.store(locales);
i18n.defaultLocale = "en";
i18n.enableFallback = true;
i18n.locale = userLocale;
window.I18n = i18n;
// Routes
import * as Routes from 'routes.js.erb';
window.Routes = Routes;

5
app/javascript/d3-tip.js vendored Normal file
View File

@ -0,0 +1,5 @@
/* eslint no-console:0 */
// JS
import * as d3Tip from 'd3-tip/dist'
window.d3.tip = d3Tip;

View File

@ -0,0 +1,8 @@
/* eslint no-console:0 */
// JS
import hljs from 'highlight.js/lib/common'
window.hljs = hljs;
// CSS
import 'highlight.js/styles/base16/tomorrow.css'

View File

@ -1,12 +0,0 @@
/* eslint no-console:0 */
// This file is automatically compiled by Webpack, along with any other files
// present in this directory. You're encouraged to place your actual application logic in
// a relevant structure within app/javascript and only use these pack files to reference
// that code so it'll be compiled.
//
// To reference this file, add <%= javascript_pack_tag 'application' %> to the appropriate
// layout file, like app/views/layouts/application.html.slim
// JS
import d3Tip from 'd3-tip'
window.d3.tip = d3Tip;

View File

@ -1,15 +0,0 @@
/* eslint no-console:0 */
// This file is automatically compiled by Webpack, along with any other files
// present in this directory. You're encouraged to place your actual application logic in
// a relevant structure within app/javascript and only use these pack files to reference
// that code so it'll be compiled.
//
// To reference this file, add <%= javascript_pack_tag 'application' %> to the appropriate
// layout file, like app/views/layouts/application.html.slim
// JS
import hljs from 'highlight.js'
window.hljs = hljs;
// CSS
import 'highlight.js/styles/base16/tomorrow.css'

View File

@ -1,12 +0,0 @@
/* eslint no-console:0 */
// This file is automatically compiled by Webpack, along with any other files
// present in this directory. You're encouraged to place your actual application logic in
// a relevant structure within app/javascript and only use these pack files to reference
// that code so it'll be compiled.
//
// To reference this file, add <%= javascript_pack_tag 'application' %> to the appropriate
// layout file, like app/views/layouts/application.html.slim
// JS
import Sortable from 'sortablejs'
window.Sortable = Sortable;

View File

@ -1,15 +0,0 @@
/* eslint no-console:0 */
// This file is automatically compiled by Webpack, along with any other files
// present in this directory. You're encouraged to place your actual application logic in
// a relevant structure within app/javascript and only use these pack files to reference
// that code so it'll be compiled.
//
// To reference this file, add <%= javascript_pack_tag 'application' %> to the appropriate
// layout file, like app/views/layouts/application.html.slim
// JS
import 'vis'
window.vis = vis;
// CSS
import 'vis/dist/vis.min.css'

View File

@ -0,0 +1,5 @@
/* eslint no-console:0 */
// JS
import Sortable from 'sortablejs'
window.Sortable = Sortable;

View File

@ -7,14 +7,14 @@
// To reference this file, add <%= stylesheet_pack_tag 'stylesheets' %> to the appropriate
// layout file, like app/views/layouts/application.html.slim
$web-font-path: '';
@import '~bootswatch/dist/yeti/variables';
@import '~bootstrap/scss/bootstrap';
@import '~bootswatch/dist/yeti/bootswatch';
$web-font-path: '//';
@import '../../node_modules/bootswatch/dist/yeti/variables';
@import '../../node_modules/bootstrap/scss/bootstrap';
@import '../../node_modules/bootswatch/dist/yeti/bootswatch';
$fa-font-path: '~@fortawesome/fontawesome-free/webfonts/';
@import '~@fortawesome/fontawesome-free/scss/fontawesome';
@import '~@fortawesome/fontawesome-free/scss/solid';
@import '~@fortawesome/fontawesome-free/scss/regular';
@import '~@fortawesome/fontawesome-free/scss/v4-shims';
@import '../../node_modules/@fortawesome/fontawesome-free/scss/solid';
@import '../../node_modules/@fortawesome/fontawesome-free/scss/regular';
@import '../../node_modules/@fortawesome/fontawesome-free/scss/v4-shims';
$opensans-path: '~opensans-webkit/fonts/';
@import '~opensans-webkit/src/sass/open-sans';
@import '../../node_modules/opensans-webkit/src/sass/open-sans';

8
app/javascript/vis.js Normal file
View File

@ -0,0 +1,8 @@
/* eslint no-console:0 */
// JS
import 'vis'
window.vis = vis;
// CSS
import 'vis-timeline/dist/vis-timeline-graph2d.css';

View File

@ -20,10 +20,11 @@ class UserMailer < ApplicationMailer
def got_new_comment(comment, request_for_comment, commenting_user)
# TODO: check whether we can take the last known locale of the receiver?
token = AuthenticationToken.generate!(request_for_comment.user)
@receiver_displayname = request_for_comment.user.displayname
@commenting_user_displayname = commenting_user.displayname
@comment_text = comment.text
@rfc_link = request_for_comment_url(request_for_comment)
@rfc_link = request_for_comment_url(request_for_comment, token: token.shared_secret)
mail(
subject: t('mailers.user_mailer.got_new_comment.subject',
commenting_user_displayname: @commenting_user_displayname), to: request_for_comment.user.email
@ -31,10 +32,11 @@ class UserMailer < ApplicationMailer
end
def got_new_comment_for_subscription(comment, subscription, from_user)
token = AuthenticationToken.generate!(subscription.user)
@receiver_displayname = subscription.user.displayname
@author_displayname = from_user.displayname
@comment_text = comment.text
@rfc_link = request_for_comment_url(subscription.request_for_comment)
@rfc_link = request_for_comment_url(subscription.request_for_comment, token: token.shared_secret)
@unsubscribe_link = unsubscribe_subscription_url(subscription)
mail(
subject: t('mailers.user_mailer.got_new_comment_for_subscription.subject',
@ -42,11 +44,12 @@ class UserMailer < ApplicationMailer
)
end
def send_thank_you_note(request_for_comments, receiver)
def send_thank_you_note(request_for_comment, receiver)
token = AuthenticationToken.generate!(request_for_comment.user)
@receiver_displayname = receiver.displayname
@author = request_for_comments.user.displayname
@thank_you_note = request_for_comments.thank_you_note
@rfc_link = request_for_comment_url(request_for_comments)
@author = request_for_comment.user.displayname
@thank_you_note = request_for_comment.thank_you_note
@rfc_link = request_for_comment_url(request_for_comment, token: token.shared_secret)
mail(subject: t('mailers.user_mailer.send_thank_you_note.subject', author: @author), to: receiver.email)
end

View File

@ -8,10 +8,19 @@ class ApplicationRecord < ActiveRecord::Base
def strip_strings
# trim whitespace from beginning and end of string attributes
# except for the `content` of CodeOcean::Files
attribute_names.without('content').each do |name|
# and except the `log` of TestrunMessages or the `output` of Testruns
attribute_names.without('content', 'log', 'output').each do |name|
if send(name.to_sym).respond_to?(:strip)
send("#{name}=".to_sym, send(name).strip)
end
end
end
def self.ransackable_associations(_auth_object = nil)
[]
end
def self.ransackable_attributes(_auth_object = nil)
[]
end
end

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
require 'securerandom'
class AuthenticationToken < ApplicationRecord
include Creation
def self.generate!(user)
create!(
shared_secret: SecureRandom.hex(32),
user: user,
expire_at: 7.days.from_now
)
end
end

View File

@ -56,6 +56,17 @@ module CodeOcean
define_method("#{role}?") { self.role == role }
end
def read
if native_file?
valid = Pathname(native_file.current_path).fnmatch? ::File.join(native_file.root, '**')
return nil unless valid
native_file.read
else
content
end
end
def ancestor_id
file_id || id
end
@ -83,12 +94,7 @@ module CodeOcean
end
def hash_content
self.hashed_content = Digest::MD5.new.hexdigest(if file_type.try(:binary?)
::File.new(native_file.file.path,
'r').read
else
content
end)
self.hashed_content = Digest::MD5.new.hexdigest(read || '')
end
private :hash_content

View File

@ -1,12 +1,10 @@
# frozen_string_literal: true
module DefaultValues
# rubocop:disable Naming/AccessorMethodName
def set_default_values_if_present(options = {})
options.each do |attribute, value|
send(:"#{attribute}=", send(:"#{attribute}") || value) if has_attribute?(attribute)
end
end
private :set_default_values_if_present
# rubocop:enable Naming/AccessorMethodName
end

View File

@ -14,4 +14,8 @@ class Consumer < ApplicationRecord
def to_s
name
end
def self.ransackable_attributes(_auth_object = nil)
%w[id]
end
end

View File

@ -16,6 +16,7 @@ class ExecutionEnvironment < ApplicationRecord
has_many :exercises
belongs_to :file_type
has_many :error_templates
belongs_to :testrun_execution_environment, optional: true, dependent: :destroy
scope :with_exercises, -> { where('id IN (SELECT execution_environment_id FROM exercises)') }
@ -60,6 +61,10 @@ class ExecutionEnvironment < ApplicationRecord
exposed_ports.join(', ')
end
def self.ransackable_attributes(_auth_object = nil)
%w[id]
end
private
def set_default_values

View File

@ -94,7 +94,7 @@ class Exercise < ApplicationRecord
(SELECT user_id,
user_type,
score,
CASE WHEN working_time >= #{StatisticsHelper::WORKING_TIME_DELTA_IN_SQL_INTERVAL} THEN '0' ELSE working_time END AS working_time_new
CASE WHEN #{StatisticsHelper.working_time_larger_delta} THEN '0' ELSE working_time END AS working_time_new
FROM
(SELECT user_id,
user_type,
@ -103,7 +103,7 @@ class Exercise < ApplicationRecord
(created_at - lag(created_at) over (PARTITION BY user_id, exercise_id
ORDER BY created_at)) AS working_time
FROM submissions
WHERE exercise_id=#{id}) AS foo) AS bar
WHERE #{self.class.sanitize_sql(['exercise_id = ?', id])}) AS foo) AS bar
GROUP BY user_id, user_type
"
end
@ -118,7 +118,7 @@ class Exercise < ApplicationRecord
(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}),
WHERE #{self.class.sanitize_sql(['exercise_id = ? and study_group_id = ?', exercise_id, study_group_id])} #{self.class.sanitize_sql(additional_filter)}),
working_time_with_deltas_ignored AS (
SELECT user_id,
user_type,
@ -126,7 +126,7 @@ class Exercise < ApplicationRecord
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
CASE WHEN #{StatisticsHelper.working_time_larger_delta} THEN '0' ELSE working_time END AS working_time_filtered
FROM working_time_between_submissions
),
working_times_with_score_expanded AS (
@ -251,7 +251,7 @@ class Exercise < ApplicationRecord
end
def get_quantiles(quantiles)
quantiles_str = "[#{quantiles.join(',')}]"
quantiles_str = self.class.sanitize_sql("[#{quantiles.join(',')}]")
result = ActiveRecord::Base.transaction do
self.class.connection.execute("
SET LOCAL intervalstyle = 'iso_8601';
@ -263,7 +263,7 @@ class Exercise < ApplicationRecord
Max(score) AS max_score,
(created_at - Lag(created_at) OVER (partition BY user_id, exercise_id ORDER BY created_at)) AS working_time
FROM submissions
WHERE exercise_id = #{id}
WHERE #{self.class.sanitize_sql(['exercise_id = ?', id])}
AND user_type = 'ExternalUser'
GROUP BY user_id,
id,
@ -273,7 +273,7 @@ class Exercise < ApplicationRecord
Sum(weight) AS max_points
FROM files
WHERE context_type = 'Exercise'
AND context_id = #{id}
AND #{self.class.sanitize_sql(['context_id = ?', id])}
AND role IN ('teacher_defined_test', 'teacher_defined_linter')
GROUP BY context_id),
-- filter for rows containing max points
@ -342,7 +342,7 @@ class Exercise < ApplicationRecord
exercise_id,
max_score,
CASE
WHEN working_time >= #{StatisticsHelper::WORKING_TIME_DELTA_IN_SQL_INTERVAL} THEN '0'
WHEN #{StatisticsHelper.working_time_larger_delta} THEN '0'
ELSE working_time
END AS working_time_new
FROM all_working_times_until_max ), result AS
@ -372,11 +372,11 @@ class Exercise < ApplicationRecord
end
def retrieve_working_time_statistics
@working_time_statistics = {}
@working_time_statistics = {'InternalUser' => {}, 'ExternalUser' => {}}
ActiveRecord::Base.transaction do
self.class.connection.execute("SET LOCAL intervalstyle = 'postgres'")
self.class.connection.execute(user_working_time_query).each do |tuple|
@working_time_statistics[tuple['user_id'].to_i] = tuple
@working_time_statistics[tuple['user_type']][tuple['user_id'].to_i] = tuple
end
end
end
@ -387,14 +387,14 @@ class Exercise < ApplicationRecord
self.class.connection.execute("
SELECT avg(working_time) as average_time
FROM
(#{user_working_time_query}) AS baz;
(#{self.class.sanitize_sql(user_working_time_query)}) AS baz;
").first['average_time']
end
end
def average_working_time_for(user_id)
def average_working_time_for(user)
retrieve_working_time_statistics if @working_time_statistics.nil?
@working_time_statistics[user_id]['working_time']
@working_time_statistics[user.class.name][user.id]['working_time']
end
def accumulated_working_time_for_only(user)
@ -445,7 +445,7 @@ class Exercise < ApplicationRecord
FILTERED_TIMES_UNTIL_MAX AS
(
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
SELECT user_id,exercise_id, max_score, CASE WHEN #{StatisticsHelper.working_time_larger_delta} 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
@ -597,4 +597,12 @@ cause: %w[submit assess remoteSubmit remoteAssess]}).distinct
WHERE exercise_id = #{id}
) AS t ON t.fv = submissions.id").distinct
end
def self.ransackable_attributes(_auth_object = nil)
%w[title]
end
def self.ransackable_associations(_auth_object = nil)
%w[execution_environment]
end
end

View File

@ -31,4 +31,8 @@ working_time: time_to_f(item.exercise.average_working_time)}
def to_s
"#{I18n.t('activerecord.models.exercise_collection.one')}: #{name} (#{id})"
end
def self.ransackable_attributes(_auth_object = nil)
%w[id]
end
end

View File

@ -246,4 +246,8 @@ class ProxyExercise < ApplicationRecord
def select_easiest_exercise(exercises)
exercises.order(:expected_difficulty).first
end
def self.ransackable_attributes(_auth_object = nil)
%w[title]
end
end

View File

@ -22,27 +22,6 @@ class RequestForComment < ApplicationRecord
# after_save :trigger_rfc_action_cable
# not used right now, finds the last submission for the respective user and exercise.
# might be helpful to check whether the exercise has been solved in the meantime.
def last_submission
Submission.find_by_sql(" select * from submissions
where exercise_id = #{exercise_id} AND
user_id = #{user_id}
order by created_at desc
limit 1").first
end
# not used any longer, since we directly saved the submission_id now.
# Was used before that to determine the submission belonging to the request_for_comment.
def last_submission_before_creation
Submission.find_by_sql(" select * from submissions
where exercise_id = #{exercise_id} AND
user_id = #{user_id} AND
'#{created_at.localtime}' > created_at
order by created_at desc
limit 1").first
end
def comments_count
submission.files.sum {|file| file.comments.size }
end
@ -89,7 +68,7 @@ class RequestForComment < ApplicationRecord
end
def last_per_user(count = 5)
from("(#{row_number_user_sql}) as request_for_comments")
from(row_number_user_sql, :request_for_comments)
.where('row_number <= ?', count)
.group('request_for_comments.id, request_for_comments.user_id, request_for_comments.user_type, ' \
'request_for_comments.exercise_id, request_for_comments.file_id, request_for_comments.question, ' \
@ -98,10 +77,18 @@ class RequestForComment < ApplicationRecord
# ugly, but necessary
end
def ransackable_associations(_auth_object = nil)
%w[exercise submission]
end
def ransackable_attributes(_auth_object = nil)
%w[solved]
end
private
def row_number_user_sql
select('id, user_id, user_type, exercise_id, file_id, question, created_at, updated_at, solved, full_score_reached, submission_id, row_number() OVER (PARTITION BY user_id, user_type ORDER BY created_at DESC) as row_number').to_sql
select('id, user_id, user_type, exercise_id, file_id, question, created_at, updated_at, solved, full_score_reached, submission_id, row_number() OVER (PARTITION BY user_id, user_type ORDER BY created_at DESC) as row_number')
end
end
end

View File

@ -62,10 +62,11 @@ class Runner < ApplicationRecord
# initializing its Runner::Connection with the given event loop. The Runner::Connection class ensures that
# this event loop is stopped after the socket was closed.
event_loop = Runner::EventLoop.new
socket = @strategy.attach_to_execution(command, event_loop, &block)
socket = @strategy.attach_to_execution(command, event_loop, starting_time, &block)
event_loop.wait
raise socket.error if socket.error.present?
rescue Runner::Error => e
e.starting_time = starting_time
e.execution_duration = Time.zone.now - starting_time
raise
end
@ -74,29 +75,34 @@ class Runner < ApplicationRecord
end
def execute_command(command, raise_exception: true)
output = {}
stdout = +''
stderr = +''
output = {
stdout: +'',
stderr: +'',
messages: [],
exit_code: 1, # default to error
}
try = 0
begin
if try.nonzero?
request_new_id
save
end
exit_code = 1 # default to error
execution_time = attach_to_execution(command) do |socket|
execution_time = attach_to_execution(command) do |socket, starting_time|
socket.on :stderr do |data|
stderr << data
output[:stderr] << data
output[:messages].push({cmd: :write, stream: :stderr, log: data, timestamp: Time.zone.now - starting_time})
end
socket.on :stdout do |data|
stdout << data
output[:stdout] << data
output[:messages].push({cmd: :write, stream: :stdout, log: data, timestamp: Time.zone.now - starting_time})
end
socket.on :exit do |received_exit_code|
exit_code = received_exit_code
output[:exit_code] = received_exit_code
end
end
output.merge!(container_execution_time: execution_time, status: exit_code.zero? ? :ok : :failed)
output.merge!(container_execution_time: execution_time, status: output[:exit_code].zero? ? :ok : :failed)
rescue Runner::Error::ExecutionTimeout => e
Rails.logger.debug { "Running command `#{command}` timed out: #{e.message}" }
output.merge!(status: :timeout, container_execution_time: e.execution_duration)
@ -115,12 +121,13 @@ class Runner < ApplicationRecord
output.merge!(status: :failed, container_execution_time: e.execution_duration)
rescue Runner::Error => e
Rails.logger.debug { "Running command `#{command}` failed: #{e.message}" }
output.merge!(status: :failed, container_execution_time: e.execution_duration)
output.merge!(status: :container_depleted, container_execution_time: e.execution_duration)
ensure
# We forward the exception if requested
raise e if raise_exception && defined?(e) && e.present?
output.merge!(stdout: stdout, stderr: stderr)
# If the process was killed with SIGKILL, it is most likely that the OOM killer was triggered.
output[:status] = :out_of_memory if output[:exit_code] == 137
end
end
@ -147,13 +154,13 @@ class Runner < ApplicationRecord
rescue Runner::Error
# An additional error was raised during synchronization
raise Runner::Error::EnvironmentNotFound.new(
"The execution environment with id #{execution_environment.id} was not found by the runner management. "\
"The execution environment with id #{execution_environment.id} was not found by the runner management. " \
'In addition, it could not be synced so that this probably indicates a permanent error.'
)
else
# No error was raised during synchronization
raise Runner::Error::EnvironmentNotFound.new(
"The execution environment with id #{execution_environment.id} was not found yet by the runner management. "\
"The execution environment with id #{execution_environment.id} was not found yet by the runner management. " \
'It has been successfully synced now so that the next request should be successful.'
)
end

View File

@ -19,4 +19,12 @@ class StudyGroup < ApplicationRecord
def to_s
name.presence || "StudyGroup #{id}"
end
def self.ransackable_attributes(_auth_object = nil)
%w[name]
end
def self.ransackable_associations(_auth_object = nil)
%w[consumer]
end
end

View File

@ -9,7 +9,7 @@ class Submission < ApplicationRecord
remoteSubmit].freeze
FILENAME_URL_PLACEHOLDER = '{filename}'
MAX_COMMENTS_ON_RECOMMENDED_RFC = 5
OLDEST_RFC_TO_SHOW = 6.months
OLDEST_RFC_TO_SHOW = 1.month
belongs_to :exercise
belongs_to :study_group, optional: true
@ -46,6 +46,8 @@ class Submission < ApplicationRecord
validates :cause, inclusion: {in: CAUSES}
attr_reader :used_execution_environment
# after_save :trigger_working_times_action_cable
def build_files_hash(files, attribute)
@ -74,7 +76,6 @@ class Submission < ApplicationRecord
end
def normalized_score
::NewRelic::Agent.add_custom_attributes({unnormalized_score: score})
if !score.nil? && !exercise.maximum_score.nil? && exercise.maximum_score.positive?
score / exercise.maximum_score
else
@ -190,12 +191,17 @@ class Submission < ApplicationRecord
result.merge(output)
end
def self.ransackable_attributes(_auth_object = nil)
%w[study_group_id]
end
private
def prepared_runner
request_time = Time.zone.now
begin
runner = Runner.for(user, exercise.execution_environment)
@used_execution_environment = AwsStudy.get_execution_environment(user, exercise)
runner = Runner.for(user, @used_execution_environment)
files = collect_files
files.reject!(&:teacher_defined_assessment?) if cause == 'run'
Rails.logger.debug { "#{Time.zone.now.getutc.inspect}: Copying files to Runner #{runner.id} for #{user_type} #{user_id} and Submission #{id}." }
@ -249,10 +255,14 @@ class Submission < ApplicationRecord
cause: 'assess', # Required to differ run and assess for RfC show
file: file, # Test file that was executed
passed: passed,
output: testrun_output,
exit_code: output[:exit_code],
status: output[:status],
output: testrun_output.presence,
container_execution_time: output[:container_execution_time],
waiting_for_container_time: output[:waiting_for_container_time]
)
TestrunMessage.create_for(testrun, output[:messages])
TestrunExecutionEnvironment.create(testrun: testrun, execution_environment: @used_execution_environment)
filename = file.filepath
@ -266,6 +276,7 @@ class Submission < ApplicationRecord
output.merge!(assessment)
output.merge!(filename: filename, message: feedback_message(file, output), weight: file.weight)
output.except!(:messages)
end
def feedback_message(file, output)

View File

@ -3,4 +3,22 @@
class Testrun < ApplicationRecord
belongs_to :file, class_name: 'CodeOcean::File', optional: true
belongs_to :submission
belongs_to :testrun_execution_environment, optional: true, dependent: :destroy
has_many :testrun_messages, dependent: :destroy
enum status: {
ok: 0,
failed: 1,
container_depleted: 2,
timeout: 3,
out_of_memory: 4,
terminated_by_client: 5,
}, _default: :ok, _prefix: true
validates :exit_code, numericality: {only_integer: true, min: 0, max: 255}, allow_nil: true
validates :status, presence: true
def log
testrun_messages.output.select(:log).map(&:log).join.presence
end
end

View File

@ -0,0 +1,6 @@
# frozen_string_literal: true
class TestrunExecutionEnvironment < ApplicationRecord
belongs_to :testrun
belongs_to :execution_environment
end

View File

@ -0,0 +1,94 @@
# frozen_string_literal: true
class TestrunMessage < ApplicationRecord
belongs_to :testrun
enum cmd: {
input: 0,
write: 1,
clear: 2,
turtle: 3,
turtlebatch: 4,
render: 5,
exit: 6,
status: 7,
hint: 8,
client_kill: 9,
exception: 10,
result: 11,
canvasevent: 12,
timeout: 13, # TODO: Shouldn't be in the data, this is a status and can be removed after the migration finished
out_of_memory: 14, # TODO: Shouldn't be in the data, this is a status and can be removed after the migration finished
}, _default: :write, _prefix: true
enum stream: {
stdin: 0,
stdout: 1,
stderr: 2,
}, _prefix: true
validates :cmd, presence: true
validates :timestamp, presence: true
validates :stream, length: {minimum: 0, allow_nil: false}, if: -> { cmd_write? }
validates :log, length: {minimum: 0, allow_nil: false}, if: -> { cmd_write? }
validate :either_data_or_log
default_scope { order(timestamp: :asc) }
scope :output, -> { where(cmd: 1, stream: %i[stdout stderr]) }
def self.create_for(testrun, messages)
# We don't want to store anything if the testrun passed
return if testrun.passed?
messages.map! do |message|
# We create a new hash and move all known keys
result = {}
result[:testrun] = testrun
result[:log] = (message.delete(:log) || message.delete(:data)) if message[:cmd] == :write || message.key?(:log)
result[:timestamp] = message.delete :timestamp
result[:stream] = message.delete :stream if message.key?(:stream)
result[:cmd] = message.delete :cmd
# The remaining keys will be stored in the `data` column
result[:data] = message.presence if message.present?
result
end
# Before storing all messages, we truncate some to save storage
filtered_messages = filter_messages_by_size testrun, messages
# An array with hashes is passed, all are stored
TestrunMessage.create!(filtered_messages)
end
def self.filter_messages_by_size(testrun, messages)
limits = if testrun.submission.cause == 'requestComments'
{data: {limit: 25, size: 0}, log: {limit: 5000, size: 0}}
else
{data: {limit: 10, size: 0}, log: {limit: 500, size: 0}}
end
filtered_messages = messages.map do |message|
if message.key?(:log) && limits[:log][:size] < limits[:log][:limit]
message[:log] = message[:log][0, limits[:log][:limit] - limits[:log][:size]]
limits[:log][:size] += message[:log].size
elsif message[:data] && limits[:data][:size] < limits[:data][:limit]
limits[:data][:size] += 1
elsif !message.key?(:log) && limits[:data][:size] < limits[:data][:limit]
# Accept short TestrunMessages (e.g. just transporting a status information)
# without increasing the `limits[:data][:limit]` before the limit is reached
else
# Clear all remaining messages
message = nil
end
message
end
filtered_messages.select(&:present?)
end
def either_data_or_log
if [data, log].count(&:present?) > 1
errors.add(log, "can't be present if data is also present")
end
end
private :either_data_or_log
end

View File

@ -6,6 +6,7 @@ class User < ApplicationRecord
ROLES = %w[admin teacher learner].freeze
belongs_to :consumer
has_many :authentication_token, dependent: :destroy
has_many :study_group_memberships, as: :user
has_many :study_groups, through: :study_group_memberships, as: :user
has_many :exercises, as: :user
@ -40,4 +41,8 @@ class User < ApplicationRecord
def to_s
displayname
end
def self.ransackable_attributes(_auth_object = nil)
%w[name email external_id consumer_id role]
end
end

View File

@ -5,7 +5,7 @@ class ExercisePolicy < AdminOrAuthorPolicy
admin?
end
%i[show? feedback? statistics? rfcs_for_exercise?].each do |action|
%i[show? feedback? statistics? external_user_statistics? rfcs_for_exercise?].each do |action|
define_method(action) { admin? || teacher_in_study_group? || (teacher? && @record.public?) || author? }
end
@ -38,7 +38,13 @@ class ExercisePolicy < AdminOrAuthorPolicy
if @user.admin?
@scope.all
elsif @user.teacher?
@scope.where('user_id = ? OR public = TRUE', @user.id)
@scope.where(
'user_id IN (SELECT user_id FROM study_group_memberships WHERE study_group_id IN (?))
OR (user_id = ? AND user_type = ?)
OR public = TRUE',
@user.study_groups.pluck(:id),
@user.id, @user.class.name
)
else
@scope.none
end

View File

@ -25,6 +25,10 @@ class RequestForCommentPolicy < ApplicationPolicy
admin? || author?
end
def clear_question?
admin? || teacher_in_study_group?
end
def edit?
admin?
end

View File

@ -126,11 +126,11 @@ module ProformaService
def add_content_to_task_file(file, task_file)
if file.native_file.present?
file = ::File.new(file.native_file.file.path, 'r')
task_file.content = file.read
file_content = file.read
task_file.content = file_content
task_file.used_by_grader = false
task_file.binary = true
task_file.mimetype = MimeMagic.by_magic(file).type
task_file.mimetype = MimeMagic.by_magic(file_content).type
else
task_file.content = file.content
task_file.used_by_grader = true

View File

@ -6,7 +6,7 @@ module CodeOcean
existing_files = File.where(name: record.name, path: record.path, file_type_id: record.file_type_id,
context_id: record.context_id, context_type: record.context_type).to_a
if !existing_files.empty? && (!record.context.is_a?(Exercise) || record.context.new_record?)
record.errors[:base] << 'Duplicate'
record.errors.add(:base, 'Duplicate')
end
end
end

View File

@ -2,8 +2,8 @@
// Force a full page reload, see https://github.com/turbolinks/turbolinks/issues/326.
Otherwise, the global variable `vis` might be uninitialized in the assets (race condition)
meta name='turbolinks-visit-control' content='reload'
= javascript_pack_tag('vis', 'data-turbolinks-track': true)
= stylesheet_pack_tag('vis', media: 'all', 'data-turbolinks-track': true)
- append_javascript_pack_tag('vis')
- append_stylesheet_pack_tag('vis')
h1 = t('breadcrumbs.dashboard.show')

View File

@ -1,4 +1,5 @@
- if model = Kernel.const_get(controller_path.classify) rescue nil
- model = controller_path.classify.constantize rescue nil
- if model
- object = model.find_by(id: params[:id])
- if model.try(:nested_resource?)
- root_element = model.model_name.human(count: 2)
@ -18,14 +19,17 @@
- title = "#{active_action} - #{application_name}"
- content_for :breadcrumbs do
.container
ul.breadcrumb
.container.mb-4
ul.breadcrumb.bg-light.px-3.py-2
- if root_element.present?
li.breadcrumb-item = root_element
li.breadcrumb-item.small
= root_element
- if current_element.present?
li.breadcrumb-item = current_element
li.breadcrumb-item.small
= current_element
- title = "#{object} - #{title}"
- else
- title = "#{model.model_name.human(count: 2)} - #{title}"
li.breadcrumb-item.active = active_action
li.breadcrumb-item.active.small
= active_action
- content_for :title, title

Some files were not shown because too many files have changed in this diff Show More