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 runs-on: ubuntu-20.04
services: services:
db: db:
image: postgres:13 image: postgres:14
env: env:
POSTGRES_PASSWORD: postgres POSTGRES_PASSWORD: postgres
ports: ports:
@ -35,22 +35,27 @@ jobs:
- name: Setup Ruby - name: Setup Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: 2.7 ruby-version: 3.1
bundler-cache: true bundler-cache: true
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
node-version: 12 node-version: 18
- name: Get yarn cache directory path - name: Get yarn cache directory path
id: yarn-cache-dir-path id: yarn-cache-dir-path
run: echo "::set-output name=dir::$(yarn config get cacheFolder)" run: echo "::set-output name=dir::$(yarn cache dir)"
- name: Manage yarn cache - name: Manage yarn, webpack and assets cache
uses: actions/cache@v2 uses: actions/cache@v3
# use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`) # use this to check for `cache-hit` (`steps.yarn-cache.outputs.cache-hit != 'true'`)
id: yarn-cache id: yarn-cache
with: 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') }} key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: | restore-keys: |
${{ runner.os }}-yarn- ${{ runner.os }}-yarn-
@ -70,11 +75,16 @@ jobs:
env: env:
RAILS_ENV: test RAILS_ENV: test
run: bundler exec rake db:schema:load run: bundler exec rake db:schema:load
- name: Precompile assets
env:
RAILS_ENV: test
run: bundler exec rake assets:precompile
- name: Run tests - name: Run tests
env: env:
RAILS_ENV: test RAILS_ENV: test
CC_TEST_REPORTER_ID: true 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 - name: Send coverage to CodeClimate
uses: paambaati/codeclimate-action@v3.0.0 uses: paambaati/codeclimate-action@v3.0.0
@ -93,8 +103,16 @@ jobs:
- name: Setup Ruby - name: Setup Ruby
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
ruby-version: 2.7 ruby-version: 3.1
bundler-cache: true bundler-cache: true
- name: Run rubocop - 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. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@v1 uses: github/codeql-action/init@v2
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # 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). # 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) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@v1 uses: github/codeql-action/autobuild@v2
# Command-line programs to run using the OS shell. # Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl # 📚 https://git.io/JvXDl
@ -56,4 +56,4 @@ jobs:
# make release # make release
- name: Perform CodeQL Analysis - 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 Max: 600
Metrics/ModuleLength: Metrics/ModuleLength:
Max: 220 Max: 225
# It's a very complicated application... # It's a very complicated application...
# #

19
Gemfile
View File

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

2
Vagrantfile vendored
View File

@ -10,7 +10,7 @@ Vagrant.configure(2) do |config|
v.cpus = 4 v.cpus = 4
end end
config.vm.network 'forwarded_port', 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, host: 7000,
guest: 7000 guest: 7000
config.vm.synced_folder '.', '/home/vagrant/codeocean' config.vm.synced_folder '.', '/home/vagrant/codeocean'

View File

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

View File

@ -1,6 +1,6 @@
$(document).on('turbolinks:load', function() { $(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) { function openSubMenu(event) {
if (this.pathname === '/') { if (this.pathname === '/') {

View File

@ -1,5 +1,5 @@
$(document).on('turbolinks:load', function() { $(document).on('turbolinks:load', function() {
$('[data-toggle="tooltip"]').tooltip(); $('[data-bs-toggle="tooltip"]').tooltip();
if($.isController('codeharbor_links')) { if($.isController('codeharbor_links')) {
if ($('.edit_codeharbor_link, .new_codeharbor_link').isPresent()) { 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 _.map($('tbody tr[data-id]'), function(element) {
return { return {
content: $('td.name', element).text(), content: $('td.name', element).text(),
id: $(element).data('id'), id: `execution_environment_${$(element).data('id')}`,
visible: false visible: false
}; };
}); });
@ -67,7 +67,7 @@ $(document).on('turbolinks:load', function() {
var setGroupVisibility = function(response) { var setGroupVisibility = function(response) {
_.each(response.docker, function(data) { _.each(response.docker, function(data) {
groups.update({ groups.update({
id: data.id, id: `execution_environment_${data.id}`,
visible: data.prewarmingPoolSize > 0 visible: data.prewarmingPoolSize > 0
}); });
}); });
@ -76,7 +76,7 @@ $(document).on('turbolinks:load', function() {
var updateChartData = function(response) { var updateChartData = function(response) {
_.each(response.docker, function(data) { _.each(response.docker, function(data) {
dataset.add({ dataset.add({
group: data.id, group: `execution_environment_${data.id}`,
x: vis.moment(), x: vis.moment(),
y: data.usedRunners 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 ($('#editor').isPresent() && CodeOceanEditor && event.originalEvent.data.url.includes("/implement")) {
if (CodeOceanEditor.isBrowserSupported()) { // This call will (amon other things) initializeEditors and load the content except for the last line
$('#alert').hide(); // It must not be called during page navigation. Otherwise, content will be duplicated!
// This call will (amon other things) initializeEditors and load the content except for the last line // Search for insertLines and Turbolinks reload / cache control
// It must not be called during page navigation. Otherwise, content will be duplicated! CodeOceanEditor.initializeEverything();
// 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-Configuration
REQUEST_FOR_COMMENTS_DELAY: 0, REQUEST_FOR_COMMENTS_DELAY: 0,
REQUEST_TOOLTIP_TIME: 5000, REQUEST_TOOLTIP_TIME: 5000,
REQUEST_TOOLTIP_DELAY: 10 * 60 * 1000, REQUEST_TOOLTIP_DELAY: 15 * 60 * 1000,
editors: [], editors: [],
editor_for_file: new Map(), editor_for_file: new Map(),
@ -78,7 +78,7 @@ var CodeOceanEditor = {
if ($('#output-' + index).isPresent()) { if ($('#output-' + index).isPresent()) {
return $('#output-' + index); return $('#output-' + index);
} else { } else {
var element = $('<pre class="mt-2">').attr('id', 'output-' + index); var element = $('<pre class="mb-2">').attr('id', 'output-' + index);
$('#output').append(element); $('#output').append(element);
return element; return element;
} }
@ -216,8 +216,8 @@ var CodeOceanEditor = {
}, },
hideSpinner: function () { hideSpinner: function () {
$('button i.fa, button i.far, button i.fas').show(); $('button i.fa-solid, button i.fa-regular').show();
$('button i.fa-spin').hide(); $('button i.fa-spin').removeClass('d-inline-block').addClass('d-none');
}, },
@ -235,10 +235,20 @@ var CodeOceanEditor = {
window.dispatchEvent(new Event('resize')); 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 // 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; return window.innerHeight - $(element).offset().top - bottom - 5;
$(element).parent().height(windowHeight); },
resizeParentOfAceEditor: function (element) {
const editorHeight = this.calculateEditorHeight(element, true);
$(element).parent().height(editorHeight);
}, },
initializeEditors: function (own_solution = false) { initializeEditors: function (own_solution = false) {
@ -259,6 +269,7 @@ var CodeOceanEditor = {
// Resize frame on window size change // Resize frame on window size change
$(window).resize(function () { $(window).resize(function () {
this.resizeParentOfAceEditor(element); this.resizeParentOfAceEditor(element);
this.resizeSidebars();
}.bind(this)); }.bind(this));
var editor = ace.edit(element); var editor = ace.edit(element);
@ -366,11 +377,9 @@ var CodeOceanEditor = {
} }
filesInstance.jstree(filesInstance.data('entries')); filesInstance.jstree(filesInstance.data('entries'));
filesInstance.on('click', 'li.jstree-leaf > a', function (event) { filesInstance.on('click', 'li.jstree-leaf > a', function (event) {
this.setActiveFile( const file_id = parseInt($(event.target).parent().attr('id'));
$(event.target).parent().text(), const frame = $('[data-file-id="' + file_id + '"]').parent();
parseInt($(event.target).parent().attr('id')) this.setActiveFile(frame.data('filename'), file_id);
);
var frame = $('[data-file-id="' + this.active_file.id + '"]').parent();
this.showFrame(frame); this.showFrame(frame);
this.toggleButtonStates(); this.toggleButtonStates();
}.bind(this)); }.bind(this));
@ -392,6 +401,7 @@ var CodeOceanEditor = {
tipButton.on('click', this.handleSideBarToggle.bind(this)); tipButton.on('click', this.handleSideBarToggle.bind(this));
} }
$('#sidebar').on('transitionend', this.resizeAceEditors.bind(this)); $('#sidebar').on('transitionend', this.resizeAceEditors.bind(this));
$('#sidebar').on('transitionend', this.resizeSidebars.bind(this));
}, },
handleSideBarToggle: function () { handleSideBarToggle: function () {
@ -435,12 +445,12 @@ var CodeOceanEditor = {
button.prop('disabled', true); button.prop('disabled', true);
button.on('click', function () { button.on('click', function () {
$('#rfc_intervention_text').hide() $('#rfc_intervention_text').hide()
$('#comment-modal').modal('show'); new bootstrap.Modal($('#comment-modal')).show();
}); });
$('#askForCommentsButton').on('click', this.requestComments.bind(this)); $('#askForCommentsButton').on('click', this.requestComments.bind(this));
$('#closeAskForCommentsButton').on('click', function () { $('#closeAskForCommentsButton').on('click', function () {
$('#comment-modal').modal('hide'); bootstrap.Modal.getInstance($('#comment-modal')).hide();
}); });
setTimeout(function () { 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')); 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) { populateCard: function (card, result, index) {
card.addClass(this.getCardClass(result)); card.addClass(this.getCardClass(result));
card.find('.card-title .filename').text(result.filename); card.find('.card-title .filename').text(result.filename);
card.find('.card-title .number').text(index + 1); 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-md-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(1).text(result.count);
if (result.weight !== 0) { 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-md-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(1).text(result.weight);
} else { } else {
// Hide score row if no score could be achieved // Hide score row if no score could be achieved
card.find('.attribute-row.row').eq(1).addClass('d-none'); 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 // Add error message from code to card
if (result.error_messages) { 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 = []; let errorMessagesToShow = [];
result.error_messages.forEach(function (item) { result.error_messages.forEach(function (item) {
@ -571,7 +575,7 @@ var CodeOceanEditor = {
} }
targetNode.append(ul); 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) { createEventHandler: function (eventType, data) {
@ -652,12 +656,17 @@ var CodeOceanEditor = {
let matches; 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)) { while (matches = this.tracepositions_regex.exec(text)) {
const frame = $('div.frame[data-filename="' + matches[1] + '"]') const frame = $('div.frame[data-filename="' + matches[1] + '"]')
if (frame.length > 0) { 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); element.html(augmented_text);
@ -694,8 +703,8 @@ var CodeOceanEditor = {
}, },
showSpinner: function (initiator) { showSpinner: function (initiator) {
$(initiator).find('i.fa, i.far, i.fas').hide(); $(initiator).find('i.fa-solid, i.fa-regular').hide();
$(initiator).find('i.fa-spin').show(); $(initiator).find('i.fa-spin').addClass('d-inline-block').removeClass('d-none');
}, },
showStatus: function (output) { showStatus: function (output) {
@ -703,9 +712,11 @@ var CodeOceanEditor = {
this.showTimeoutMessage(); this.showTimeoutMessage();
} else if (output.status === 'container_depleted') { } else if (output.status === 'container_depleted') {
this.showContainerDepletedMessage(); this.showContainerDepletedMessage();
} else if (output.status === 'out_of_memory') {
this.showOutOfMemoryMessage();
} else if (output.stderr) { } else if (output.stderr) {
$.flash.danger({ $.flash.danger({
icon: ['fa', 'fa-bug'], icon: ['fa-solid', 'fa-bug'],
text: $('#run').data('message-failure') text: $('#run').data('message-failure')
}); });
Sentry.captureException(JSON.stringify(output)); Sentry.captureException(JSON.stringify(output));
@ -738,14 +749,21 @@ var CodeOceanEditor = {
showContainerDepletedMessage: function () { showContainerDepletedMessage: function () {
$.flash.danger({ $.flash.danger({
icon: ['fa', 'fa-clock-o'], icon: ['fa-regular', 'fa-clock'],
text: $('#editor').data('message-depleted') text: $('#editor').data('message-depleted')
}); });
}, },
showOutOfMemoryMessage: function () {
$.flash.info({
icon: ['fa-regular', 'fa-clock'],
text: $('#editor').data('message-out-of-memory')
});
},
showTimeoutMessage: function () { showTimeoutMessage: function () {
$.flash.info({ $.flash.info({
icon: ['fa', 'fa-clock-o'], icon: ['fa-regular', 'fa-clock'],
text: $('#editor').data('message-timeout') text: $('#editor').data('message-timeout')
}); });
}, },
@ -766,7 +784,7 @@ var CodeOceanEditor = {
event.preventDefault(); event.preventDefault();
this.createSubmission('#create-file', null, function (response) { this.createSubmission('#create-file', null, function (response) {
$('#code_ocean_file_context_id').val(response.id); $('#code_ocean_file_context_id').val(response.id);
$('#modal-file').modal('show'); new bootstrap.Modal($('#modal-file')).show();
}.bind(this)); }.bind(this));
}, },
@ -774,6 +792,7 @@ var CodeOceanEditor = {
$('#toggle-sidebar-output').on('click', this.hideOutputBar.bind(this)); $('#toggle-sidebar-output').on('click', this.hideOutputBar.bind(this));
$('#toggle-sidebar-output-collapsed').on('click', this.showOutputBar.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.resizeAceEditors.bind(this));
$('#output_sidebar').on('transitionend', this.resizeSidebars.bind(this));
}, },
showOutputBar: function () { showOutputBar: function () {
@ -789,7 +808,7 @@ var CodeOceanEditor = {
}, },
initializeSideBarTooltips: function () { initializeSideBarTooltips: function () {
$('[data-toggle="tooltip"]').tooltip() $('[data-bs-toggle="tooltip"]').tooltip()
}, },
initializeDescriptionToggle: function () { initializeDescriptionToggle: function () {
@ -797,12 +816,13 @@ var CodeOceanEditor = {
$('a#toggle').on('click', this.toggleDescriptionCard.bind(this)); $('a#toggle').on('click', this.toggleDescriptionCard.bind(this));
}, },
toggleDescriptionCard: function () { toggleDescriptionCard: function (event) {
$('#description-card').toggleClass('description-card-collapsed').toggleClass('description-card'); $('#description-card').toggleClass('description-card-collapsed').toggleClass('description-card');
$('#description-symbol').toggleClass('fa-chevron-down').toggleClass('fa-chevron-right'); $('#description-symbol').toggleClass('fa-chevron-down').toggleClass('fa-chevron-right');
var toggle = $('a#toggle'); var toggle = $('a#toggle');
toggle.text(toggle.text() == toggle.data('hide') ? toggle.data('show') : toggle.data('hide')); toggle.text(toggle.text() == toggle.data('hide') ? toggle.data('show') : toggle.data('hide'));
this.resizeAceEditors(); this.resizeAceEditors();
this.resizeSidebars();
event.preventDefault(); event.preventDefault();
}, },
@ -835,11 +855,7 @@ var CodeOceanEditor = {
const percentile75 = data['working_time_75_percentile']; const percentile75 = data['working_time_75_percentile'];
const accumulatedWorkTimeUser = data['working_time_accumulated']; const accumulatedWorkTimeUser = data['working_time_accumulated'];
let minTimeIntervention = 10 * 60 * 1000; let minTimeIntervention = 20 * 60 * 1000;
if ($('#editor').data('exercise-id') === 909) {
// 30 minutes for our large Map exercise
minTimeIntervention = 30 * 60 * 1000;
}
let timeUntilIntervention; let timeUntilIntervention;
if ((accumulatedWorkTimeUser - percentile75) > 0) { if ((accumulatedWorkTimeUser - percentile75) > 0) {
@ -861,17 +877,21 @@ var CodeOceanEditor = {
clearInterval(tid); clearInterval(tid);
// timeUntilIntervention passed // timeUntilIntervention passed
if (editor.data('tips-interventions')) { 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({ $.ajax({
data: { data: {
intervention_type: 'TipIntervention' intervention_type: 'TipsIntervention'
}, },
dataType: 'json', dataType: 'json',
type: 'POST', type: 'POST',
url: interventionSaveUrl url: interventionSaveUrl
}); });
} else if (editor.data('break-interventions')) { } 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({ $.ajax({
data: { data: {
intervention_type: 'BreakIntervention' intervention_type: 'BreakIntervention'
@ -885,7 +905,12 @@ var CodeOceanEditor = {
// only show intervention if user did not requested for a comment already // only show intervention if user did not requested for a comment already
if (!button.prop('disabled')) { if (!button.prop('disabled')) {
$('#rfc_intervention_text').show(); $('#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({ $.ajax({
data: { data: {
intervention_type: 'QuestionIntervention' intervention_type: 'QuestionIntervention'
@ -928,7 +953,6 @@ var CodeOceanEditor = {
CodeOceanEditor.editors = []; CodeOceanEditor.editors = [];
this.initializeRegexes(); this.initializeRegexes();
this.initializeCodePilot(); this.initializeCodePilot();
$('.score, #development-environment').show();
this.configureEditors(); this.configureEditors();
this.initializeEditors(); this.initializeEditors();
this.initializeEventHandlers(); this.initializeEventHandlers();
@ -944,6 +968,7 @@ var CodeOceanEditor = {
this.renderScore(); this.renderScore();
this.showFirstFile(); this.showFirstFile();
this.resizeAceEditors(); this.resizeAceEditors();
this.resizeSidebars();
this.initializeDeadlines(); this.initializeDeadlines();
CodeOceanEditorTips.initializeEventHandlers(); CodeOceanEditorTips.initializeEventHandlers();

View File

@ -1,5 +1,8 @@
CodeOceanEditorEvaluation = { CodeOceanEditorEvaluation = {
chunkBuffer: [{streamedResponse: true}], 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 * Scoring-Functions
@ -99,6 +102,11 @@ CodeOceanEditorEvaluation = {
})) { })) {
this.showTimeoutMessage(); this.showTimeoutMessage();
} }
if (_.some(response, function (result) {
return result.status === 'out_of_memory';
})) {
this.showOutOfMemoryMessage();
}
if (_.some(response, function (result) { if (_.some(response, function (result) {
return result.status === 'container_depleted'; return result.status === 'container_depleted';
})) { })) {
@ -199,26 +207,39 @@ CodeOceanEditorEvaluation = {
return; return;
} }
if (output.stdout !== undefined && !output.stdout.startsWith("<img")) {
output.stdout = _.escape(output.stdout);
}
var element = this.findOrCreateOutputElement(index); 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 (!colorize) {
if (output.stdout !== undefined && output.stdout !== '') { if (output.stdout !== undefined && output.stdout !== '') {
//element.append(output.stdout) output.stdout = output.stdout.replace(this.nonPrintableRegEx, "")
element.text(element.text() + output.stdout)
element.append(output.stdout)
//element.text(element.text() + output.stdout)
} }
if (output.stderr !== undefined && output.stderr !== '') { if (output.stderr !== undefined && output.stderr !== '') {
//element.append('StdErr: ' + output.stderr); output.stderr = output.stderr.replace(this.nonPrintableRegEx, "")
element.text('StdErr: ' + element.text() + output.stderr);
element.append('StdErr: ' + output.stderr);
//element.text('StdErr: ' + element.text() + output.stderr);
} }
} else if (output.stderr) { } else if (output.stderr) {
//element.addClass('text-warning').append(output.stderr); output.stderr = output.stderr.replace(this.nonPrintableRegEx, "")
element.addClass('text-warning').text(element.text() + output.stderr);
element.addClass('text-warning').append(output.stderr);
//element.addClass('text-warning').text(element.text() + output.stderr);
this.QaApiOutputBuffer.stderr += output.stderr; this.QaApiOutputBuffer.stderr += output.stderr;
} else if (output.stdout) { } else if (output.stdout) {
//element.addClass('text-success').append(output.stdout); output.stdout = output.stdout.replace(this.nonPrintableRegEx, "")
element.addClass('text-success').text(element.text() + output.stdout);
element.addClass('text-success').append(output.stdout);
//element.addClass('text-success').text(element.text() + output.stdout);
this.QaApiOutputBuffer.stdout += output.stdout; this.QaApiOutputBuffer.stdout += output.stdout;
} else { } else {
element.addClass('text-muted').text($('#output').data('message-no-output')); 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('turtlebatch', this.handleTurtlebatchCommand.bind(this));
this.websocket.on('render', this.renderWebsocketOutput.bind(this)); this.websocket.on('render', this.renderWebsocketOutput.bind(this));
this.websocket.on('exit', this.handleExitCommand.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('status', this.showStatus.bind(this));
this.websocket.on('hint', this.showHint.bind(this)); this.websocket.on('hint', this.showHint.bind(this));
}, },

View File

@ -4,9 +4,9 @@ CodeOceanEditorFlowr = {
'<div class="card mb-2">' + '<div class="card mb-2">' +
'<div id="{{headingId}}" role="tab" class="card-header">' + '<div id="{{headingId}}" role="tab" class="card-header">' +
'<div class="card-title mb-0">' + '<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">' + '<div class="clearfix" role="button">' +
'<i class="fa" aria-hidden="true"></i>' + '<i class="fa-solid" aria-hidden="true"></i>' +
'<span>' + '<span>' +
'</span>' + '</span>' +
'</div>' + '</div>' +
@ -14,7 +14,7 @@ CodeOceanEditorFlowr = {
'</div>' + '</div>' +
'</div>' + '</div>' +
'<div id="{{collapseId}}" role="tabpanel" aria-labelledby="{{headingId}}" class="card card-collapse collapse">' + '<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>' +
'</div>', '</div>',
@ -93,7 +93,7 @@ CodeOceanEditorFlowr = {
var body = resultTile.find('.card-body'); var body = resultTile.find('.card-body');
body.html(result.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>'); '<%= I18n.t('exercises.implement.flowr.go_to_question') %></a>');
body.find('.btn').on('click', CodeOceanEditor.createEventHandler('editor_flowr_click_question', questionUrl)); body.find('.btn').on('click', CodeOceanEditor.createEventHandler('editor_flowr_click_question', questionUrl));
@ -112,7 +112,7 @@ CodeOceanEditorCodePilot = {
QaApiOutputBuffer: {'stdout': '', 'stderr': ''}, QaApiOutputBuffer: {'stdout': '', 'stderr': ''},
initializeCodePilot: function () { 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'); $('#editor-column').addClass('col-md-10').removeClass('col-md-12');
$('#questions-column').addClass('col-md-2'); $('#questions-column').addClass('col-md-2');
@ -161,7 +161,7 @@ CodeOceanEditorRequestForComments = {
this.createSubmission($('#requestComments'), null, createRequestForComments.bind(this)); this.createSubmission($('#requestComments'), null, createRequestForComments.bind(this));
$('#comment-modal').modal('hide'); bootstrap.Modal.getInstance($('#comment-modal')).hide();
$('#question').val(''); $('#question').val('');
// we disabled the button to prevent that the user spams RFCs, but decided against this now. // we disabled the button to prevent that the user spams RFCs, but decided against this now.
//var button = $('#requestComments'); //var button = $('#requestComments');

View File

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

View File

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

View File

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

View File

@ -9,7 +9,7 @@ $(document).on('turbolinks:load', function() {
event.preventDefault(); event.preventDefault();
if (!$(this).hasClass('disabled')) { if (!$(this).hasClass('disabled')) {
var parent = $(this).parents('.form-group'); var parent = $(this).parents('.mb-3');
var original_input = parent.find('.original-input'); var original_input = parent.find('.original-input');
var alternative_input = parent.find('.alternative-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. // entering links.
var linkDialogTitle = "<%= I18n.t('components.markdown_editor.insert_link.dialog_title', default: 'Insert link') %>"; 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 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 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 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 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 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"; var defaultHelpHoverTitle = "Markdown Editing Help";
@ -193,7 +193,7 @@
var regexText; var regexText;
var replacementText; 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/)) { if (navigator.userAgent.match(/Chrome/)) {
"X".match(/()./); "X".match(/()./);
} }
@ -1018,7 +1018,7 @@
text = 'http://' + text; text = 'http://' + text;
} }
$(dialog).modal('hide'); bootstrap.Modal.getInstance($(dialog)).hide();
callback(text); callback(text);
return false; return false;
@ -1032,7 +1032,7 @@
// <div class="modal-dialog"> // <div class="modal-dialog">
// <div class="modal-content"> // <div class="modal-content">
// <div class="modal-header"> // <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> // <h3 class="modal-title">Modal title</h3>
// </div> // </div>
// <div class="modal-body"> // <div class="modal-body">
@ -1062,7 +1062,7 @@
// The header. // The header.
var header = doc.createElement("div"); var header = doc.createElement("div");
header.className = "modal-header"; 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); dialogContent.appendChild(header);
// The body. // The body.
@ -1082,7 +1082,7 @@
// The input text box // The input text box
var formGroup = doc.createElement("div"); var formGroup = doc.createElement("div");
formGroup.className = "form-group"; formGroup.className = "mb-3";
form.appendChild(formGroup); form.appendChild(formGroup);
var label = doc.createElement("label"); var label = doc.createElement("label");
@ -1144,15 +1144,15 @@
range.select(); range.select();
} }
$(dialog).on('shown', function () { $(dialog).on('shown.bs.modal', function () {
input.focus(); input.focus();
}); });
$(dialog).on('hidden', function () { $(dialog).on('hidden.bs.modal', function () {
dialog.parentNode.removeChild(dialog); dialog.parentNode.removeChild(dialog);
}); });
$(dialog).modal() new bootstrap.Modal($(dialog)).show();
}, 0); }, 0);
}; };
@ -1360,8 +1360,8 @@
button.appendChild(buttonImage); button.appendChild(buttonImage);
button.id = id + postfix; button.id = id + postfix;
button.title = title; button.title = title;
button.setAttribute("data-toggle", "tooltip"); button.setAttribute("data-bs-toggle", "tooltip");
button.setAttribute("data-placement", "top"); button.setAttribute("data-bs-placement", "top");
if (textOp) if (textOp)
button.textOp = textOp; button.textOp = textOp;
setupButton(button, true); setupButton(button, true);
@ -1381,51 +1381,51 @@
}; };
var group1 = makeGroup(1); 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.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 fa-italic", bindCommand("doItalic"), 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); 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); return this.doLinkOrImage(chunk, postProcessing, false);
}), group2); }), 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); return this.doLinkOrImage(chunk, postProcessing, true);
}), group2); }), 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.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 fa-code", bindCommand("doCode"), 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); 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); this.doList(chunk, postProcessing, false);
}), group3); }), 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); this.doList(chunk, postProcessing, true);
}), group3); }), 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); 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(); }; buttons.undo.execute = function (manager) { if (manager) manager.undo(); };
var redoTitle = /win/.test(nav.platform.toLowerCase()) ? 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.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 "<%= 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(); }; buttons.redo.execute = function (manager) { if (manager) manager.redo(); };
if (helpOptions) { if (helpOptions) {
var group5 = makeGroup(5); var group5 = makeGroup(5);
group5.className = group5.className + " ml-auto"; group5.className = group5.className + " ms-auto";
var helpButton = document.createElement("button"); var helpButton = document.createElement("button");
var helpButtonImage = document.createElement("i"); var helpButtonImage = document.createElement("i");
helpButtonImage.className = "m-1 fa fa-info"; helpButtonImage.className = "m-1 fa-solid fa-info";
helpButton.appendChild(helpButtonImage); helpButton.appendChild(helpButtonImage);
helpButton.className = "btn btn-info btn-sm"; helpButton.className = "btn btn-info btn-sm";
helpButton.id = "wmd-help-button" + postfix; helpButton.id = "wmd-help-button" + postfix;
helpButton.isHelp = true; helpButton.isHelp = true;
helpButton.setAttribute("data-toggle", "tooltip"); helpButton.setAttribute("data-bs-toggle", "tooltip");
helpButton.setAttribute("data-placement", "top"); helpButton.setAttribute("data-bs-placement", "top");
helpButton.title = helpOptions.title || defaultHelpHoverTitle; helpButton.title = helpOptions.title || defaultHelpHoverTitle;
helpButton.onclick = helpOptions.handler; helpButton.onclick = helpOptions.handler;
@ -1793,7 +1793,7 @@
// //
// Since this is essentially a backwards-moving regex, it's susceptible to // Since this is essentially a backwards-moving regex, it's susceptible to
// catastrophic backtracking and can cause the browser to hang; // 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 // Hence we replaced this by a simple state machine that just goes through the
// lines and checks for a), b), and c). // lines and checks for a), b), and c).

View File

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

View File

@ -29,10 +29,14 @@ $(document).on('turbolinks:load', function () {
}; };
const handleResponse = function (response) { const handleResponse = function (response) {
// Always print stdout and stderr
printOutput(response);
// If an error occurred, print it too
if (response.status === 'timeout') { if (response.status === 'timeout') {
printTimeout(response); printTimeout(response);
} else { } else if (response.status === 'out_of_memory') {
printOutput(response); printOutOfMemory(response);
} }
}; };
@ -71,12 +75,19 @@ $(document).on('turbolinks:load', function () {
}; };
const printTimeout = function (output) { const printTimeout = function (output) {
const element = $.append('<p>'); const element = $('<p>');
element.addClass('text-danger'); element.addClass('text-danger');
element.text($('#shell').data('message-timeout')); element.text($('#shell').data('message-timeout'));
$('#output').append(element); $('#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()) { if ($('#shell').isPresent()) {
const command = $('#command') const command = $('#command')
command.focus(); 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 fileTypeById = {};
var showActiveFile = function() { var showActiveFile = function() {
$('tr.active').removeClass('active');
$('tr#submission-' + currentSubmission).addClass('active');
var session = editor.getSession(); var session = editor.getSession();
var fileType = fileTypeById[active_file.file_type_id]; var fileType = fileTypeById[active_file.file_type_id];
session.setMode(fileType.editor_mode); session.setMode(fileType.editor_mode);
@ -81,6 +83,7 @@ $(document).on('turbolinks:load', function() {
$('tr[data-id]>.clickable').each(function(index, element) { $('tr[data-id]>.clickable').each(function(index, element) {
element = $(element); element = $(element);
element.parent().attr('id', 'submission-' + index);
element.click(function() { element.click(function() {
slider.val(index); slider.val(index);
slider.change() slider.change()
@ -105,7 +108,7 @@ $(document).on('turbolinks:load', function() {
stopReplay = function() { stopReplay = function() {
clearInterval(playInterval); clearInterval(playInterval);
playInterval = undefined; 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) { playButton.on('click', function(event) {
@ -124,7 +127,7 @@ $(document).on('turbolinks:load', function() {
stopReplay(); stopReplay();
} }
}, 1000); }, 1000);
playButton.find('span.fa').removeClass('fa-play').addClass('fa-pause') playButton.find('span.fa-solid').removeClass('fa-play').addClass('fa-pause')
} else { } else {
stopReplay(); stopReplay();
} }

View File

@ -3,7 +3,7 @@ $(document).on('turbolinks:load', function() {
if ($.isController('exercises') && $('.working-time-graphs').isPresent()) { if ($.isController('exercises') && $('.working-time-graphs').isPresent()) {
var working_times = $('#data').data('working-time'); var working_times = $('#data').data('working-time');
function get_minutes (timestamp){ function get_minutes (timestamp){
try{ try{
hours = timestamp.split(":")[0]; hours = timestamp.split(":")[0];
@ -160,7 +160,6 @@ $(document).on('turbolinks:load', function() {
groupRanges += groupWidth; groupRanges += groupWidth;
} }
while (groupRanges < maximum_minutes); while (groupRanges < maximum_minutes);
console.log(maximum_minutes);
var clusterCount = 0, var clusterCount = 0,
sum = 0, sum = 0,

View File

@ -12,7 +12,16 @@ h1, h2, h3, h4, h5, h6 {
color: rgba(70, 70, 70, 1); 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; margin-right: 0.5em;
} }
@ -35,6 +44,10 @@ span.caret {
} }
} }
.btn-default {
--bs-btn-disabled-border-color: transparent;
}
.progress { .progress {
margin: 0; margin: 0;
border: 1px solid #CCCCCC; border: 1px solid #CCCCCC;
@ -51,13 +64,17 @@ span.caret {
.navbar { .navbar {
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
font-weight: 500; font-weight: 500;
font-size: 0.85rem;
.dropdown-item {
padding: 1rem 1.5rem;
}
} }
.attribute-row + .attribute-row { .attribute-row + .attribute-row {
margin-top: 0.5em; margin-top: 0.5em;
} }
.badge-pill { .rounded-pill {
font-size: 100%; font-size: 100%;
font-weight: 500; font-weight: 500;
} }

View File

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

View File

@ -1,6 +1,6 @@
// Place all the styles related to the Comments controller here. // Place all the styles related to the Comments controller here.
// They will automatically be included in application.css. // 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 { .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=="); 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 { .editor {
height: 100%; height: 100%;
width: 100%; width: 100%;
@ -48,10 +44,6 @@ button i.fa-spin {
vertical-align: bottom; vertical-align: bottom;
} }
#development-environment {
display: none;
}
#dummy { #dummy {
display: none; display: none;
} }
@ -203,8 +195,8 @@ button i.fa-spin {
visibility: visible; visibility: visible;
} }
.enforce-big-top-margin { .enforce-big-bottom-margin {
margin-top: 15px !important; margin-bottom: 15px !important;
} }
.enforce-bottom-margin { .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"; content: "\f139";
} }
[data-toggle="collapse"].collapsed .fa:before { [data-bs-toggle="collapse"].collapsed .fa-solid:before {
content: "\f13a"; content: "\f13a";
} }

View File

@ -1,3 +1,3 @@
// Place all the styles related to the FileTemplates controller here. // Place all the styles related to the FileTemplates controller here.
// They will automatically be included in application.css. // 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; margin-bottom: 1em;
} }
.form-group {
&:not(:last-child) {
margin-right: 1em;
}
}
input, select { input, select {
min-width: 200px !important; min-width: 200px !important;
} }

View File

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

View File

@ -12,7 +12,7 @@ class LaExercisesChannel < ApplicationCable::Channel
private private
def specific_channel 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]}" "la_exercises_#{params[:exercise_id]}_channel_study_group_#{params[:study_group_id]}"
end end
end end

View File

@ -2,7 +2,7 @@
class ApplicationController < ActionController::Base class ApplicationController < ActionController::Base
include ApplicationHelper include ApplicationHelper
include Pundit include Pundit::Authorization
MEMBER_ACTIONS = %i[destroy edit show update].freeze MEMBER_ACTIONS = %i[destroy edit show update].freeze
@ -15,9 +15,11 @@ class ApplicationController < ActionController::Base
rescue_from ActionController::InvalidAuthenticityToken, with: :render_csrf_error rescue_from ActionController::InvalidAuthenticityToken, with: :render_csrf_error
def current_user def current_user
::NewRelic::Agent.add_custom_attributes(external_user_id: session[:external_user_id], @current_user ||= ExternalUser.find_by(id: session[:external_user_id]) ||
session_user_id: session[:user_id]) login_from_session ||
@current_user ||= ExternalUser.find_by(id: session[:external_user_id]) || login_from_session || login_from_other_sources || nil login_from_other_sources ||
login_from_authentication_token ||
nil
end end
def require_user! def require_user!
@ -34,6 +36,13 @@ class ApplicationController < ActionController::Base
end end
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 def set_sentry_context
return if current_user.blank? return if current_user.blank?
@ -73,7 +82,7 @@ class ApplicationController < ActionController::Base
private :render_error private :render_error
def switch_locale(&action) 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 locale = session[:locale] || I18n.default_locale
Sentry.set_extras(locale: locale) Sentry.set_extras(locale: locale)
I18n.with_locale(locale, &action) I18n.with_locale(locale, &action)
@ -98,4 +107,18 @@ class ApplicationController < ActionController::Base
@embed_options @embed_options
end end
private :load_embed_options 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 end

View File

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

View File

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

View File

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

View File

@ -11,7 +11,7 @@ class CommunitySolutionsController < ApplicationController
# GET /community_solutions # GET /community_solutions
def index def index
@community_solutions = CommunitySolution.all @community_solutions = CommunitySolution.all.paginate(page: params[:page], per_page: per_page_param)
authorize! authorize!
end end
@ -85,7 +85,7 @@ class CommunitySolutionsController < ApplicationController
private private
def authorize! def authorize!
authorize(@community_solution) authorize(@community_solution || @community_solutions)
end end
# Use callbacks to share common setup or constraints between actions. # 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.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. # exercise_id.exists? ==> the user has submitted the results of an exercise to the consumer.
# Only the lti_parameters are deleted. # 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? if exercise_id.nil?
session.delete(:external_user_id) session.delete(:external_user_id)
session.delete(:study_group_id) session.delete(:study_group_id)
@ -29,8 +29,10 @@ module Lti
session.delete(:lti_exercise_id) session.delete(:lti_exercise_id)
session.delete(:lti_parameters_id) session.delete(:lti_parameters_id)
end 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 end
private :clear_lti_session_data private :clear_lti_session_data
@ -136,7 +138,6 @@ module Lti
private :return_to_consumer private :return_to_consumer
def send_score(submission) def send_score(submission)
::NewRelic::Agent.add_custom_attributes({score: submission.normalized_score, session: session})
unless (0..MAXIMUM_SCORE).cover?(submission.normalized_score) unless (0..MAXIMUM_SCORE).cover?(submission.normalized_score)
raise Error.new("Score #{submission.normalized_score} must be between 0 and #{MAXIMUM_SCORE}!") raise Error.new("Score #{submission.normalized_score} must be between 0 and #{MAXIMUM_SCORE}!")
end end

View File

@ -129,12 +129,7 @@ module RedirectBehavior
lti_parameters_id: session[:lti_parameters_id] lti_parameters_id: session[:lti_parameters_id]
) )
lti_parameter = LtiParameter.where(external_users_id: @submission.user_id, path = lti_return_path(submission_id: @submission.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)))
clear_lti_session_data(@submission.exercise_id, @submission.user_id) clear_lti_session_data(@submission.exercise_id, @submission.user_id)
respond_to do |format| respond_to do |format|
format.html { redirect_to(path) } format.html { redirect_to(path) }

View File

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

View File

@ -12,7 +12,7 @@ class ErrorTemplateAttributesController < ApplicationController
# GET /error_template_attributes.json # GET /error_template_attributes.json
def index def index
@error_template_attributes = ErrorTemplateAttribute.all.order('important DESC', :key, @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! authorize!
end end
@ -42,7 +42,7 @@ class ErrorTemplateAttributesController < ApplicationController
respond_to do |format| respond_to do |format|
if @error_template_attribute.save if @error_template_attribute.save
format.html do 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 end
format.json { render :show, status: :created, location: @error_template_attribute } format.json { render :show, status: :created, location: @error_template_attribute }
else else
@ -59,7 +59,7 @@ class ErrorTemplateAttributesController < ApplicationController
respond_to do |format| respond_to do |format|
if @error_template_attribute.update(error_template_attribute_params) if @error_template_attribute.update(error_template_attribute_params)
format.html do 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 end
format.json { render :show, status: :ok, location: @error_template_attribute } format.json { render :show, status: :ok, location: @error_template_attribute }
else else
@ -76,7 +76,7 @@ class ErrorTemplateAttributesController < ApplicationController
@error_template_attribute.destroy @error_template_attribute.destroy
respond_to do |format| respond_to do |format|
format.html do 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 end
format.json { head :no_content } format.json { head :no_content }
end end

View File

@ -11,7 +11,7 @@ class ErrorTemplatesController < ApplicationController
# GET /error_templates # GET /error_templates
# GET /error_templates.json # GET /error_templates.json
def index 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! authorize!
end end
@ -40,7 +40,7 @@ class ErrorTemplatesController < ApplicationController
respond_to do |format| respond_to do |format|
if @error_template.save 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 } format.json { render :show, status: :created, location: @error_template }
else else
format.html { render :new } format.html { render :new }
@ -55,7 +55,7 @@ class ErrorTemplatesController < ApplicationController
authorize! authorize!
respond_to do |format| respond_to do |format|
if @error_template.update(error_template_params) 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 } format.json { render :show, status: :ok, location: @error_template }
else else
format.html { render :edit } format.html { render :edit }
@ -70,14 +70,14 @@ class ErrorTemplatesController < ApplicationController
authorize! authorize!
@error_template.destroy @error_template.destroy
respond_to do |format| 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 } format.json { head :no_content }
end end
end end
def add_attribute def add_attribute
authorize! 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| respond_to do |format|
format.html { redirect_to @error_template } format.html { redirect_to @error_template }
format.json { head :no_content } format.json { head :no_content }
@ -86,7 +86,7 @@ class ErrorTemplatesController < ApplicationController
def remove_attribute def remove_attribute
authorize! 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| respond_to do |format|
format.html { redirect_to @error_template } format.html { redirect_to @error_template }
format.json { head :no_content } format.json { head :no_content }

View File

@ -30,7 +30,7 @@ class ExecutionEnvironmentsController < ApplicationController
def execute_command def execute_command
runner = Runner.for(current_user, @execution_environment) runner = Runner.for(current_user, @execution_environment)
output = runner.execute_command(params[:command], raise_exception: false) output = runner.execute_command(params[:command], raise_exception: false)
render json: output render json: output.except(:messages)
end end
def working_time_query def working_time_query
@ -44,7 +44,7 @@ class ExecutionEnvironmentsController < ApplicationController
FROM FROM
(SELECT user_id, (SELECT user_id,
exercise_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 FROM
(SELECT user_id, (SELECT user_id,
exercise_id, exercise_id,
@ -121,7 +121,7 @@ class ExecutionEnvironmentsController < ApplicationController
private :execution_environment_params private :execution_environment_params
def index 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! authorize!
end end
@ -158,7 +158,7 @@ class ExecutionEnvironmentsController < ApplicationController
def show def show
if @execution_environment.testing_framework? 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
end end
@ -172,8 +172,7 @@ class ExecutionEnvironmentsController < ApplicationController
begin begin
Runner.strategy_class.sync_environment(@execution_environment) Runner.strategy_class.sync_environment(@execution_environment)
rescue Runner::Error => e rescue Runner::Error => e
Rails.logger.debug { "Runner error while synchronizing execution environment with id #{@execution_environment.id}: #{e.message}" } Rails.logger.warn { "Runner error while synchronizing execution environment with id #{@execution_environment.id}: #{e.message}" }
Sentry.capture_exception(e)
redirect_to @execution_environment, alert: t('execution_environments.index.synchronize.failure', error: e.message) redirect_to @execution_environment, alert: t('execution_environments.index.synchronize.failure', error: e.message)
else else
redirect_to @execution_environment, notice: t('execution_environments.index.synchronize.success') 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] before_action :set_exercise_collection, only: %i[show edit update destroy statistics]
def index def index
@exercise_collections = ExerciseCollection.all.paginate(page: params[:page]) @exercise_collections = ExerciseCollection.all.paginate(page: params[:page], per_page: per_page_param)
authorize! authorize!
end 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_execution_environments, only: %i[index create edit new update]
before_action :set_exercise_and_authorize, before_action :set_exercise_and_authorize,
only: MEMBER_ACTIONS + %i[clone implement working_times intervention search run statistics submit reload feedback 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] requests_for_comments study_group_dashboard export_external_check export_external_confirm
before_action :set_external_user_and_authorize, only: [:statistics] 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_file_types, only: %i[create edit new update]
before_action :set_course_token, only: [:implement] before_action :set_course_token, only: [:implement]
before_action :set_available_tips, only: %i[implement show new edit] before_action :set_available_tips, only: %i[implement show new edit]
skip_before_action :verify_authenticity_token, skip_before_action :verify_authenticity_token, only: %i[import_exercise import_uuid_check]
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]
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], raise: false
skip_after_action :verify_policy_scoped, only: %i[import_exercise import_uuid_check export_external_confirm],
raise: false
def authorize! def authorize!
authorize(@exercise || @exercises) authorize(@exercise || @exercises)
end end
private :authorize! private :authorize!
def max_intervention_count_per_day def max_intervention_count_per_day
@ -51,7 +51,7 @@ raise: false
exercise = @exercise.duplicate(public: false, token: nil, user: current_user) exercise = @exercise.duplicate(public: false, token: nil, user: current_user)
exercise.send(:generate_token) exercise.send(:generate_token)
if exercise.save 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 else
flash[:danger] = t('shared.message_failure') flash[:danger] = t('shared.message_failure')
redirect_to(@exercise) redirect_to(@exercise)
@ -67,6 +67,7 @@ raise: false
end end
subpaths.flatten.uniq subpaths.flatten.uniq
end end
private :collect_paths private :collect_paths
def create def create
@ -103,7 +104,7 @@ raise: false
def feedback def feedback
authorize! 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| @submissions = @feedbacks.map do |feedback|
feedback.exercise.final_submission(feedback.user) feedback.exercise.final_submission(feedback.user)
end end
@ -128,6 +129,7 @@ raise: false
end end
def export_external_confirm def export_external_confirm
authorize!
@exercise.uuid = SecureRandom.uuid if @exercise.uuid.nil? @exercise.uuid = SecureRandom.uuid if @exercise.uuid.nil?
error = ExerciseService::PushExternal.call( error = ExerciseService::PushExternal.call(
@ -176,7 +178,7 @@ raise: false
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
exercise = ::ProformaService::Import.call(zip: tempfile, user: user) exercise = ::ProformaService::Import.call(zip: tempfile, user: user)
exercise.save! exercise.save!
return render json: {}, status: :created render json: {}, status: :created
end end
rescue Proforma::ExerciseNotOwned rescue Proforma::ExerciseNotOwned
render json: {}, status: :unauthorized render json: {}, status: :unauthorized
@ -192,12 +194,14 @@ raise: false
api_key = authorization_header&.split(' ')&.second api_key = authorization_header&.split(' ')&.second
user_by_codeharbor_token(api_key) user_by_codeharbor_token(api_key)
end end
private :user_from_api_key private :user_from_api_key
def user_by_codeharbor_token(api_key) def user_by_codeharbor_token(api_key)
link = CodeharborLink.find_by(api_key: api_key) link = CodeharborLink.find_by(api_key: api_key)
link&.user link&.user
end end
private :user_by_codeharbor_token private :user_by_codeharbor_token
def exercise_params def exercise_params
@ -225,6 +229,7 @@ raise: false
) )
end end
end end
private :exercise_params private :exercise_params
def handle_file_uploads def handle_file_uploads
@ -241,6 +246,7 @@ raise: false
end end
end end
end end
private :handle_file_uploads private :handle_file_uploads
def handle_exercise_tips def handle_exercise_tips
@ -258,6 +264,7 @@ raise: false
redirect_to(edit_exercise_path(@exercise)) redirect_to(edit_exercise_path(@exercise))
end end
end end
private :handle_exercise_tips private :handle_exercise_tips
def update_exercise_tips(exercise_tips, parent_exercise_tip_id, rank) def update_exercise_tips(exercise_tips, parent_exercise_tip_id, rank)
@ -283,6 +290,7 @@ raise: false
end end
result result
end end
private :update_exercise_tips private :update_exercise_tips
def implement def implement
@ -348,6 +356,7 @@ raise: false
@course_token = '702cbd2a-c84c-4b37-923a-692d7d1532d0' @course_token = '702cbd2a-c84c-4b37-923a-692d7d1532d0'
end end
end end
private :set_course_token private :set_course_token
def set_available_tips def set_available_tips
@ -374,13 +383,14 @@ raise: false
# Return an array with top-level tips # Return an array with top-level tips
@tips = nested_tips.values.select {|tip| tip.parent_exercise_tip_id.nil? } @tips = nested_tips.values.select {|tip| tip.parent_exercise_tip_id.nil? }
end end
private :set_available_tips private :set_available_tips
def working_times def working_times
working_time_accumulated = @exercise.accumulated_working_time_for_only(current_user) working_time_accumulated = @exercise.accumulated_working_time_for_only(current_user)
working_time_75_percentile = @exercise.get_quantiles([0.75]).first working_time_75_percentile = @exercise.get_quantiles([0.75]).first
render(json: {working_time_75_percentile: working_time_75_percentile, render(json: {working_time_75_percentile: working_time_75_percentile,
working_time_accumulated: working_time_accumulated}) working_time_accumulated: working_time_accumulated})
end end
def intervention def intervention
@ -401,8 +411,9 @@ working_time_accumulated: working_time_accumulated})
search_text = params[:search_text] search_text = params[:search_text]
search = Search.new(user: current_user, exercise: @exercise, search: search_text) search = Search.new(user: current_user, exercise: @exercise, search: search_text)
begin search.save begin
render(json: {success: 'true'}) search.save
render(json: {success: 'true'})
rescue StandardError rescue StandardError
render(json: {success: 'false', error: "could not save search: #{$ERROR_INFO}"}) render(json: {success: 'false', error: "could not save search: #{$ERROR_INFO}"})
end end
@ -410,7 +421,7 @@ working_time_accumulated: working_time_accumulated})
def index def index
@search = policy_scope(Exercise).ransack(params[:q]) @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! authorize!
end end
@ -424,12 +435,14 @@ working_time_accumulated: working_time_accumulated})
def set_execution_environments def set_execution_environments
@execution_environments = ExecutionEnvironment.all.order(:name) @execution_environments = ExecutionEnvironment.all.order(:name)
end end
private :set_execution_environments private :set_execution_environments
def set_exercise_and_authorize def set_exercise_and_authorize
@exercise = Exercise.find(params[:id]) @exercise = Exercise.find(params[:id])
authorize! authorize!
end end
private :set_exercise_and_authorize private :set_exercise_and_authorize
def set_external_user_and_authorize def set_external_user_and_authorize
@ -438,23 +451,25 @@ working_time_accumulated: working_time_accumulated})
authorize! authorize!
end end
end end
private :set_external_user_and_authorize private :set_external_user_and_authorize
def set_file_types def set_file_types
@file_types = FileType.all.order(:name) @file_types = FileType.all.order(:name)
end end
private :set_file_types private :set_file_types
def collect_set_and_unset_exercise_tags def collect_set_and_unset_exercise_tags
@search = policy_scope(Tag).ransack(params[:q]) @tags = policy_scope(Tag)
@tags = @search.result.order(:name)
checked_exercise_tags = @exercise.exercise_tags checked_exercise_tags = @exercise.exercise_tags
checked_tags = checked_exercise_tags.collect(&:tag).to_set checked_tags = checked_exercise_tags.collect(&:tag).to_set
unchecked_tags = Tag.all.to_set.subtract checked_tags unchecked_tags = Tag.all.to_set.subtract checked_tags
@exercise_tags = checked_exercise_tags + unchecked_tags.collect do |tag| @exercise_tags = checked_exercise_tags + unchecked_tags.collect do |tag|
ExerciseTag.new(exercise: @exercise, tag: tag) ExerciseTag.new(exercise: @exercise, tag: tag)
end end
end end
private :collect_set_and_unset_exercise_tags private :collect_set_and_unset_exercise_tags
def show def show
@ -466,55 +481,63 @@ working_time_accumulated: working_time_accumulated})
end end
def statistics def statistics
if @external_user # Show general statistic page for specific exercise
# Render statistics page for one specific external user user_statistics = {'InternalUser' => {}, 'ExternalUser' => {}}
authorize(@external_user, :statistics?)
if policy(@exercise).detailed_statistics? query = Submission.select('user_id, user_type, MAX(score) AS maximum_score, COUNT(id) AS runs')
@submissions = Submission.where(user: @external_user, .where(exercise_id: @exercise.id)
exercise_id: @exercise.id).in_study_group_of(current_user).order('created_at') .group('user_id, user_type')
interventions = UserExerciseIntervention.where('user_id = ? AND exercise_id = ?', @external_user.id,
@exercise.id) query = if policy(@exercise).detailed_statistics?
@all_events = (@submissions + interventions).sort_by(&:created_at) query
@deltas = @all_events.map.with_index do |item, index| elsif !policy(@exercise).detailed_statistics? && current_user.study_groups.count.positive?
delta = item.created_at - @all_events[index - 1].created_at if index.positive? query.where(study_groups: current_user.study_groups.pluck(:id), cause: 'submit')
delta.nil? || (delta > StatisticsHelper::WORKING_TIME_DELTA_IN_SECONDS) ? 0 : delta else
end # e.g. internal user without any study groups, show no submissions
@working_times_until = [] query.where('false')
@all_events.each_with_index do |_, index| end
@working_times_until.push((format_time_difference(@deltas[0..index].sum) if index.positive?))
end query.each do |tuple|
else user_statistics[tuple['user_type']][tuple['user_id'].to_i] = tuple
final_submissions = Submission.where(user: @external_user,
exercise_id: @exercise.id).in_study_group_of(current_user).final
@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?
end
@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
render locals: {
user_statistics: user_statistics,
}
end
def external_user_statistics
# Render statistics page for one specific external user
if policy(@exercise).detailed_statistics?
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)
@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
end
@working_times_until = []
@all_events.each_with_index do |_, index|
@working_times_until.push((format_time_difference(@deltas[0..index].sum) if index.positive?))
end
else
final_submissions = Submission.where(user: @external_user,
exercise_id: @exercise.id).in_study_group_of(current_user).final
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?
end
@all_events = submissions
end
render 'exercises/external_users/statistics'
end end
def submit def submit
@ -534,8 +557,6 @@ working_time_accumulated: working_time_accumulated})
end end
def transmit_lti_score def transmit_lti_score
::NewRelic::Agent.add_custom_attributes({submission: @submission.id,
normalized_score: @submission.normalized_score})
response = send_score(@submission) response = send_score(@submission)
if response[:status] == 'success' if response[:status] == 'success'
@ -552,6 +573,7 @@ working_time_accumulated: working_time_accumulated})
end end
end end
end end
private :transmit_lti_score private :transmit_lti_score
def update def update

View File

@ -10,7 +10,7 @@ class ExternalUsersController < ApplicationController
def index def index
@search = ExternalUser.ransack(params[:q]) @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! authorize!
end end
@ -32,7 +32,7 @@ class ExternalUsersController < ApplicationController
score, score,
id, id,
CASE CASE
WHEN working_time >= #{StatisticsHelper::WORKING_TIME_DELTA_IN_SQL_INTERVAL} THEN '0' WHEN #{StatisticsHelper.working_time_larger_delta} THEN '0'
ELSE working_time ELSE working_time
END AS working_time_new END AS working_time_new
FROM FROM

View File

@ -19,7 +19,7 @@ class FileTemplatesController < ApplicationController
# GET /file_templates # GET /file_templates
# GET /file_templates.json # GET /file_templates.json
def index 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! authorize!
end end
@ -48,7 +48,7 @@ class FileTemplatesController < ApplicationController
respond_to do |format| respond_to do |format|
if @file_template.save 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 } format.json { render :show, status: :created, location: @file_template }
else else
format.html { render :new } format.html { render :new }
@ -63,7 +63,7 @@ class FileTemplatesController < ApplicationController
authorize! authorize!
respond_to do |format| respond_to do |format|
if @file_template.update(file_template_params) 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 } format.json { render :show, status: :ok, location: @file_template }
else else
format.html { render :edit } format.html { render :edit }
@ -78,7 +78,7 @@ class FileTemplatesController < ApplicationController
authorize! authorize!
@file_template.destroy @file_template.destroy
respond_to do |format| 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 } format.json { head :no_content }
end end
end end

View File

@ -33,7 +33,7 @@ class FileTypesController < ApplicationController
private :file_type_params private :file_type_params
def index 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! authorize!
end 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) # get the latest submission for this user that also has a test run (i.e. structured_errors if applicable)
submission = Submission.joins(:testruns) submission = Submission.joins(:testruns)
.where(submissions: {user_id: current_user.id, user_type: current_user.class.name}) .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 # Return if no submission was found
if submission.blank? || @embed_options[:disable_hints] || @embed_options[:hide_test_results] 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_activation_token, only: :activate
before_action :require_reset_password_token, only: :reset_password before_action :require_reset_password_token, only: :reset_password
before_action :set_user, only: MEMBER_ACTIONS 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] after_action :verify_authorized, except: %i[activate forgot_password reset_password]
def activate def activate
@ -33,9 +32,15 @@ class InternalUsersController < ApplicationController
def create def create
@user = InternalUser.new(internal_user_params) @user = InternalUser.new(internal_user_params)
@user.role = role_param if current_user.admin?
authorize! authorize!
@user.send(:setup_activation) @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 end
def deliver_reset_password_instructions def deliver_reset_password_instructions
@ -63,15 +68,20 @@ class InternalUsersController < ApplicationController
def index def index
@search = InternalUser.ransack(params[:q]) @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! authorize!
end end
def internal_user_params 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 end
private :internal_user_params private :internal_user_params
def role_param
params.require(:internal_user).permit(:role)[:role]
end
private :role_param
def new def new
@user = InternalUser.new @user = InternalUser.new
authorize! authorize!
@ -129,6 +139,7 @@ class InternalUsersController < ApplicationController
# the form by another user. Otherwise, the update might fail if an # the form by another user. Otherwise, the update might fail if an
# activation_token or password_reset_token is present # activation_token or password_reset_token is present
@user.validate_password = current_user == @user @user.validate_password = current_user == @user
@user.role = role_param if current_user.admin?
update_and_respond(object: @user, params: internal_user_params) update_and_respond(object: @user, params: internal_user_params)
end end

View File

@ -15,7 +15,7 @@ class ProxyExercisesController < ApplicationController
user: current_user) user: current_user)
proxy_exercise.send(:generate_token) proxy_exercise.send(:generate_token)
if proxy_exercise.save 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 else
flash[:danger] = t('shared.message_failure') flash[:danger] = t('shared.message_failure')
redirect_to(@proxy_exercise) redirect_to(@proxy_exercise)
@ -51,7 +51,7 @@ class ProxyExercisesController < ApplicationController
def index def index
@search = policy_scope(ProxyExercise).ransack(params[:q]) @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! authorize!
end end

View File

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

View File

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

View File

@ -24,7 +24,7 @@ class SessionsController < ApplicationController
store_lti_session_data(consumer: @consumer, parameters: params) store_lti_session_data(consumer: @consumer, parameters: params)
store_nonce(params[:oauth_nonce]) store_nonce(params[:oauth_nonce])
if params[:custom_redirect_target] if params[:custom_redirect_target]
redirect_to(params[:custom_redirect_target]) redirect_to(URI.parse(params[:custom_redirect_target].to_s).path)
else else
redirect_to(implement_exercise_path(@exercise), redirect_to(implement_exercise_path(@exercise),
notice: t("sessions.create_through_lti.session_#{lti_outcome_service?(@exercise.id, @current_user.id) ? 'with' : 'without'}_outcome", 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 def destroy_through_lti
@submission = Submission.find(params[:submission_id]) @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) clear_lti_session_data(@submission.exercise_id, @submission.user_id)
end end

View File

@ -7,7 +7,7 @@ class StudyGroupsController < ApplicationController
def index def index
@search = policy_scope(StudyGroup).ransack(params[:q]) @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! authorize!
end end

View File

@ -9,10 +9,10 @@ class SubmissionsController < ApplicationController
before_action :require_user! before_action :require_user!
before_action :set_submission, only: %i[download download_file render_file run score show statistics test] 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, only: %i[download show]
before_action :set_files_and_specific_file, only: %i[download_file render_file run test] 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] 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 def create
@submission = Submission.new(submission_params) @submission = Submission.new(submission_params)
@ -27,8 +27,8 @@ class SubmissionsController < ApplicationController
stringio = Zip::OutputStream.write_buffer do |zio| stringio = Zip::OutputStream.write_buffer do |zio|
@files.each do |file| @files.each do |file|
zio.put_next_entry(file.filepath) zio.put_next_entry(file.filepath.delete_prefix('/'))
zio.write(file.content.presence || file.native_file.read) zio.write(file.read)
end end
# zip exercise description # zip exercise description
@ -39,7 +39,7 @@ class SubmissionsController < ApplicationController
# zip .co file # zip .co file
zio.put_next_entry('.co') zio.put_next_entry('.co')
zio.write(File.read(id_file)) zio.write(File.read(id_file))
File.delete(id_file) if File.exist?(id_file) FileUtils.rm_rf(id_file)
# zip client scripts # zip client scripts
scripts_path = 'app/assets/remote_scripts' scripts_path = 'app/assets/remote_scripts'
@ -56,22 +56,18 @@ class SubmissionsController < ApplicationController
def download_file def download_file
raise Pundit::NotAuthorizedError if @embed_options[:disable_download] raise Pundit::NotAuthorizedError if @embed_options[:disable_download]
if @file.native_file? send_data(@file.read, filename: @file.name_with_extension)
send_file(@file.native_file.path)
else
send_data(@file.content, filename: @file.name_with_extension)
end
end end
def index def index
@search = Submission.ransack(params[:q]) @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! authorize!
end end
def render_file def render_file
if @file.native_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 else
render(plain: @file.content) render(plain: @file.content)
end end
@ -85,10 +81,14 @@ class SubmissionsController < ApplicationController
hijack do |tubesock| hijack do |tubesock|
client_socket = 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| client_socket.onclose do |_event|
runner_socket&.close(:terminated_by_client) runner_socket&.close(:terminated_by_client)
@testrun[:status] ||= :terminated_by_client
end end
client_socket.onmessage do |raw_event| client_socket.onmessage do |raw_event|
@ -97,9 +97,17 @@ class SubmissionsController < ApplicationController
# Otherwise, we expect to receive a JSON: Parsing. # Otherwise, we expect to receive a JSON: Parsing.
event = JSON.parse(raw_event).deep_symbolize_keys 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 when :client_kill
@testrun[:status] = :terminated_by_client
close_client_connection(client_socket) close_client_connection(client_socket)
Rails.logger.debug('Client exited container.') Rails.logger.debug('Client exited container.')
when :result, :canvasevent, :exception when :result, :canvasevent, :exception
@ -125,68 +133,88 @@ class SubmissionsController < ApplicationController
end end
end end
@output = +'' @testrun[:output] = +''
durations = @submission.run(@file) do |socket| durations = @submission.run(@file) do |socket, starting_time|
runner_socket = socket runner_socket = socket
@testrun[:starting_time] = starting_time
client_socket.send_data JSON.dump({cmd: :status, status: :container_running}) client_socket.send_data JSON.dump({cmd: :status, status: :container_running})
runner_socket.on :stdout do |data| runner_socket.on :stdout do |data|
json_data = prepare data, :stdout message = retrieve_message_from_output data, :stdout
@output << json_data[0, max_output_buffer_size - @output.size] @testrun[:output] << message[:data][0, max_output_buffer_size - @testrun[:output].size] if message[:data]
client_socket.send_data(json_data) send_and_store client_socket, message
end end
runner_socket.on :stderr do |data| runner_socket.on :stderr do |data|
json_data = prepare data, :stderr message = retrieve_message_from_output data, :stderr
@output << json_data[0, max_output_buffer_size - @output.size] @testrun[:output] << message[:data][0, max_output_buffer_size - @testrun[:output].size] if message[:data]
client_socket.send_data(json_data) send_and_store client_socket, message
end end
runner_socket.on :exit do |exit_code| runner_socket.on :exit do |exit_code|
@testrun[:exit_code] = exit_code
exit_statement = 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) 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) t('exercises.implement.no_output_exit_failure', timestamp: l(Time.zone.now, format: :short), exit_code: exit_code)
elsif exit_code.zero? elsif exit_code.zero?
@testrun[:status] = :ok
"\n#{t('exercises.implement.exit_successful', timestamp: l(Time.zone.now, format: :short), exit_code: exit_code)}" "\n#{t('exercises.implement.exit_successful', timestamp: l(Time.zone.now, format: :short), exit_code: exit_code)}"
else else
@testrun[:status] = :failed
"\n#{t('exercises.implement.exit_failure', timestamp: l(Time.zone.now, format: :short), exit_code: exit_code)}" "\n#{t('exercises.implement.exit_failure', timestamp: l(Time.zone.now, format: :short), exit_code: exit_code)}"
end 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) close_client_connection(client_socket)
end end
end end
@container_execution_time = durations[:execution_duration] @testrun[:container_execution_time] = durations[:execution_duration]
@waiting_for_container_time = durations[:waiting_duration] @testrun[:waiting_for_container_time] = durations[:waiting_duration]
rescue Runner::Error::ExecutionTimeout => e 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) close_client_connection(client_socket)
Rails.logger.debug { "Running a submission timed out: #{e.message}" } Rails.logger.debug { "Running a submission timed out: #{e.message}" }
@output = "timeout: #{@output}" @testrun[:status] ||= :timeout
@testrun[:output] = "timeout: #{@testrun[:output]}"
extract_durations(e) extract_durations(e)
rescue Runner::Error => 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) close_client_connection(client_socket)
@testrun[:status] ||= :container_depleted
Rails.logger.debug { "Runner error while running a submission: #{e.message}" } Rails.logger.debug { "Runner error while running a submission: #{e.message}" }
extract_durations(e) extract_durations(e)
ensure ensure
save_run_output save_testrun_output 'run'
end end
def score def score
hijack do |tubesock| 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]
tubesock.send_data(JSON.dump(@submission.calculate_score)) # The score is stored separately, we can forward it to the client immediately
# To enable hints when scoring a submission, uncomment the next line: tubesock.send_data(JSON.dump(@submission.calculate_score))
# send_hints(tubesock, StructuredError.where(submission: @submission)) # To enable hints when scoring a submission, uncomment the next line:
rescue Runner::Error => e # send_hints(tubesock, StructuredError.where(submission: @submission))
tubesock.send_data JSON.dump({cmd: :status, status: :container_depleted}) kill_client_socket(tubesock)
Rails.logger.debug { "Runner error while scoring submission #{@submission.id}: #{e.message}" } rescue Runner::Error => e
ensure extract_durations(e)
kill_client_socket(tubesock) 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
end end
@ -196,14 +224,22 @@ class SubmissionsController < ApplicationController
def test def test
hijack do |tubesock| 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]
tubesock.send_data(JSON.dump(@submission.test(@file))) # The score is stored separately, we can forward it to the client immediately
rescue Runner::Error => e tubesock.send_data(JSON.dump(@submission.test(@file)))
tubesock.send_data JSON.dump({cmd: :status, status: :container_depleted}) kill_client_socket(tubesock)
Rails.logger.debug { "Runner error while testing submission #{@submission.id}: #{e.message}" } rescue Runner::Error => e
ensure extract_durations(e)
kill_client_socket(tubesock) 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
end end
@ -221,6 +257,7 @@ class SubmissionsController < ApplicationController
end end
def kill_client_socket(client_socket) 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.send_data JSON.dump({cmd: :exit})
client_socket.close client_socket.close
end end
@ -240,7 +277,7 @@ class SubmissionsController < ApplicationController
# parse validation token # parse validation token
content = "#{remote_evaluation_mapping.validation_token}\n" content = "#{remote_evaluation_mapping.validation_token}\n"
# parse remote request url # parse remote request url
content += "#{request.base_url}/evaluate\n" content += "#{evaluate_url}\n"
@submission.files.each do |file| @submission.files.each do |file|
content += "#{file.filepath}=#{file.file_id}\n" content += "#{file.filepath}=#{file.file_id}\n"
end end
@ -249,21 +286,33 @@ class SubmissionsController < ApplicationController
end end
def extract_durations(error) def extract_durations(error)
@container_execution_time = error.execution_duration @testrun[:starting_time] = error.starting_time
@waiting_for_container_time = error.waiting_duration @testrun[:container_execution_time] = error.execution_duration
@testrun[:waiting_for_container_time] = error.waiting_duration
end end
def extract_errors def extract_errors
results = [] results = []
if @output.present? if @testrun[:output].present?
@submission.exercise.execution_environment.error_templates.each do |template| @submission.exercise.execution_environment.error_templates.each do |template|
pattern = Regexp.new(template.signature).freeze 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
end end
results results
end 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 def max_output_buffer_size
if @submission.cause == 'requestComments' if @submission.cause == 'requestComments'
5000 5000
@ -272,28 +321,25 @@ class SubmissionsController < ApplicationController
end end
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 def sanitize_filename
params[:filename].gsub(/\.json$/, '') params[:filename].gsub(/\.json$/, '')
end end
# save the output of this "run" as a "testrun" (scoring runs are saved in submission.rb) # save the output of this "run" as a "testrun" (scoring runs are saved in submission.rb)
def save_run_output def save_testrun_output(cause)
Testrun.create( testrun = Testrun.create!(
file: @file, file: @file,
cause: 'run', passed: @testrun[:passed],
cause: cause,
submission: @submission, submission: @submission,
output: @output, exit_code: @testrun[:exit_code], # might be nil, e.g., when the run did not finish
container_execution_time: @container_execution_time, status: @testrun[:status],
waiting_for_container_time: @waiting_for_container_time 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 end
def send_hints(tubesock, errors) def send_hints(tubesock, errors)
@ -301,7 +347,7 @@ class SubmissionsController < ApplicationController
errors = errors.to_a.uniq(&:hint) errors = errors.to_a.uniq(&:hint)
errors.each do |error| 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
end end
@ -327,10 +373,26 @@ class SubmissionsController < ApplicationController
authorize! authorize!
end 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 = 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 rescue JSON::ParserError
false {cmd: :write, stream: stream, data: data}
end end
end end

View File

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

View File

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

View File

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

View File

@ -18,11 +18,11 @@ module ApplicationHelper
end end
def empty def empty
tag.i(nil, class: 'empty fa fa-minus') tag.i(nil, class: 'empty fa-solid fa-minus')
end end
def label_column(label) def label_column(label)
tag.div(class: 'col-sm-3') do tag.div(class: 'col-md-3') do
tag.strong do tag.strong do
I18n.translation_present?("activerecord.attributes.#{label}") ? t("activerecord.attributes.#{label}") : t(label) I18n.translation_present?("activerecord.attributes.#{label}") ? t("activerecord.attributes.#{label}") : t(label)
end end
@ -31,13 +31,21 @@ module ApplicationHelper
private :label_column private :label_column
def no 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 end
def progress_bar(value) def progress_bar(value)
tag.div(class: value ? 'progress' : 'disabled progress') do tag.div(class: value ? 'progress' : 'disabled progress') do
tag.div(value ? "#{value}%" : '', 'aria-valuemax': 100, 'aria-valuemin': 0, 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
end end
@ -64,13 +72,13 @@ module ApplicationHelper
end end
def value_column(value) def value_column(value)
tag.div(class: 'col-sm-9') do tag.div(class: 'col-md-9') do
block_given? ? yield : symbol_for(value) block_given? ? yield : symbol_for(value)
end end
end end
private :value_column private :value_column
def yes def yes
tag.i(nil, class: 'fa fa-check') tag.i(nil, class: 'fa-solid fa-check')
end end
end end

View File

@ -28,7 +28,7 @@ class PagedownFormBuilder < ActionView::Helpers::FormBuilder
def wmd_preview def wmd_preview
@template.tag.div(nil, class: 'wmd-preview', @template.tag.div(nil, class: 'wmd-preview',
id: "wmd-preview-#{base_id}") id: "wmd-preview-#{base_id}")
end end
def show_wmd_preview? def show_wmd_preview?

View File

@ -2,7 +2,9 @@
module StatisticsHelper module StatisticsHelper
WORKING_TIME_DELTA_IN_SECONDS = 5.minutes 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 def statistics_data
[ [
@ -79,7 +81,7 @@ module StatisticsHelper
{ {
key: 'container_requests_per_minute', key: 'container_requests_per_minute',
name: t('statistics.entries.exercises.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', unit: '/min',
}, },
{ {
@ -179,7 +181,7 @@ module StatisticsHelper
key: 'rfcs', key: 'rfcs',
name: t('activerecord.models.request_for_comment.other'), name: t('activerecord.models.request_for_comment.other'),
data: RequestForComment.in_range(from, to) 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'), .group('key').order('key'),
}, },
{ {
@ -187,7 +189,7 @@ module StatisticsHelper
name: t('statistics.entries.request_for_comments.percent_solved'), name: t('statistics.entries.request_for_comments.percent_solved'),
data: RequestForComment.in_range(from, to) data: RequestForComment.in_range(from, to)
.where(solved: true) .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'), .group('key').order('key'),
}, },
{ {
@ -195,14 +197,14 @@ module StatisticsHelper
name: t('statistics.entries.request_for_comments.percent_soft_solved'), name: t('statistics.entries.request_for_comments.percent_soft_solved'),
data: RequestForComment.in_range(from, to).unsolved data: RequestForComment.in_range(from, to).unsolved
.where(full_score_reached: true) .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'), .group('key').order('key'),
}, },
{ {
key: 'rfcs_unsolved', key: 'rfcs_unsolved',
name: t('statistics.entries.request_for_comments.percent_unsolved'), name: t('statistics.entries.request_for_comments.percent_unsolved'),
data: RequestForComment.in_range(from, to).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'), .group('key').order('key'),
}, },
] ]
@ -215,14 +217,14 @@ module StatisticsHelper
name: t('statistics.entries.users.active'), name: t('statistics.entries.users.active'),
data: ExternalUser.joins(:submissions) data: ExternalUser.joins(:submissions)
.where(submissions: {created_at: from..to}) .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'), .group('key').order('key'),
}, },
{ {
key: 'submissions', key: 'submissions',
name: t('statistics.entries.exercises.submissions'), name: t('statistics.entries.exercises.submissions'),
data: Submission.where(created_at: from..to) 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'), .group('key').order('key'),
axis: 'right', axis: 'right',
}, },

View File

@ -10,12 +10,14 @@
// JS // JS
import 'jquery'; import 'jquery';
import 'jquery-ujs' 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 'chosen-js/chosen.jquery';
import 'jstree'; import 'jstree';
import 'underscore'; import 'underscore';
import 'd3'; import 'd3';
import '@sentry/browser'; import '@sentry/browser';
import 'sorttable';
window.bootstrap = bootstrap; // Publish bootstrap in global namespace
window._ = _; // Publish underscore's `_` in global namespace window._ = _; // Publish underscore's `_` in global namespace
window.d3 = d3; // Publish d3 in global namespace window.d3 = d3; // Publish d3 in global namespace
window.Sentry = Sentry; // Publish sentry 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/selectable.css'
import 'jquery-ui/themes/base/sortable.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 // Routes
import * as Routes from 'routes.js.erb'; import * as Routes from 'routes.js.erb';
window.Routes = Routes; 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 // To reference this file, add <%= stylesheet_pack_tag 'stylesheets' %> to the appropriate
// layout file, like app/views/layouts/application.html.slim // layout file, like app/views/layouts/application.html.slim
$web-font-path: ''; $web-font-path: '//';
@import '~bootswatch/dist/yeti/variables'; @import '../../node_modules/bootswatch/dist/yeti/variables';
@import '~bootstrap/scss/bootstrap'; @import '../../node_modules/bootstrap/scss/bootstrap';
@import '~bootswatch/dist/yeti/bootswatch'; @import '../../node_modules/bootswatch/dist/yeti/bootswatch';
$fa-font-path: '~@fortawesome/fontawesome-free/webfonts/'; $fa-font-path: '~@fortawesome/fontawesome-free/webfonts/';
@import '~@fortawesome/fontawesome-free/scss/fontawesome'; @import '~@fortawesome/fontawesome-free/scss/fontawesome';
@import '~@fortawesome/fontawesome-free/scss/solid'; @import '../../node_modules/@fortawesome/fontawesome-free/scss/solid';
@import '~@fortawesome/fontawesome-free/scss/regular'; @import '../../node_modules/@fortawesome/fontawesome-free/scss/regular';
@import '~@fortawesome/fontawesome-free/scss/v4-shims'; @import '../../node_modules/@fortawesome/fontawesome-free/scss/v4-shims';
$opensans-path: '~opensans-webkit/fonts/'; $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) def got_new_comment(comment, request_for_comment, commenting_user)
# TODO: check whether we can take the last known locale of the receiver? # 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 @receiver_displayname = request_for_comment.user.displayname
@commenting_user_displayname = commenting_user.displayname @commenting_user_displayname = commenting_user.displayname
@comment_text = comment.text @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( mail(
subject: t('mailers.user_mailer.got_new_comment.subject', subject: t('mailers.user_mailer.got_new_comment.subject',
commenting_user_displayname: @commenting_user_displayname), to: request_for_comment.user.email commenting_user_displayname: @commenting_user_displayname), to: request_for_comment.user.email
@ -31,10 +32,11 @@ class UserMailer < ApplicationMailer
end end
def got_new_comment_for_subscription(comment, subscription, from_user) def got_new_comment_for_subscription(comment, subscription, from_user)
token = AuthenticationToken.generate!(subscription.user)
@receiver_displayname = subscription.user.displayname @receiver_displayname = subscription.user.displayname
@author_displayname = from_user.displayname @author_displayname = from_user.displayname
@comment_text = comment.text @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) @unsubscribe_link = unsubscribe_subscription_url(subscription)
mail( mail(
subject: t('mailers.user_mailer.got_new_comment_for_subscription.subject', subject: t('mailers.user_mailer.got_new_comment_for_subscription.subject',
@ -42,11 +44,12 @@ class UserMailer < ApplicationMailer
) )
end 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 @receiver_displayname = receiver.displayname
@author = request_for_comments.user.displayname @author = request_for_comment.user.displayname
@thank_you_note = request_for_comments.thank_you_note @thank_you_note = request_for_comment.thank_you_note
@rfc_link = request_for_comment_url(request_for_comments) @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) mail(subject: t('mailers.user_mailer.send_thank_you_note.subject', author: @author), to: receiver.email)
end end

View File

@ -8,10 +8,19 @@ class ApplicationRecord < ActiveRecord::Base
def strip_strings def strip_strings
# trim whitespace from beginning and end of string attributes # trim whitespace from beginning and end of string attributes
# except for the `content` of CodeOcean::Files # 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) if send(name.to_sym).respond_to?(:strip)
send("#{name}=".to_sym, send(name).strip) send("#{name}=".to_sym, send(name).strip)
end end
end end
end end
def self.ransackable_associations(_auth_object = nil)
[]
end
def self.ransackable_attributes(_auth_object = nil)
[]
end
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 } define_method("#{role}?") { self.role == role }
end 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 def ancestor_id
file_id || id file_id || id
end end
@ -83,12 +94,7 @@ module CodeOcean
end end
def hash_content def hash_content
self.hashed_content = Digest::MD5.new.hexdigest(if file_type.try(:binary?) self.hashed_content = Digest::MD5.new.hexdigest(read || '')
::File.new(native_file.file.path,
'r').read
else
content
end)
end end
private :hash_content private :hash_content

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -22,27 +22,6 @@ class RequestForComment < ApplicationRecord
# after_save :trigger_rfc_action_cable # 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 def comments_count
submission.files.sum {|file| file.comments.size } submission.files.sum {|file| file.comments.size }
end end
@ -89,7 +68,7 @@ class RequestForComment < ApplicationRecord
end end
def last_per_user(count = 5) 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) .where('row_number <= ?', count)
.group('request_for_comments.id, request_for_comments.user_id, request_for_comments.user_type, ' \ .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, ' \ 'request_for_comments.exercise_id, request_for_comments.file_id, request_for_comments.question, ' \
@ -98,10 +77,18 @@ class RequestForComment < ApplicationRecord
# ugly, but necessary # ugly, but necessary
end end
def ransackable_associations(_auth_object = nil)
%w[exercise submission]
end
def ransackable_attributes(_auth_object = nil)
%w[solved]
end
private private
def row_number_user_sql 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 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 # 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. # this event loop is stopped after the socket was closed.
event_loop = Runner::EventLoop.new 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 event_loop.wait
raise socket.error if socket.error.present? raise socket.error if socket.error.present?
rescue Runner::Error => e rescue Runner::Error => e
e.starting_time = starting_time
e.execution_duration = Time.zone.now - starting_time e.execution_duration = Time.zone.now - starting_time
raise raise
end end
@ -74,29 +75,34 @@ class Runner < ApplicationRecord
end end
def execute_command(command, raise_exception: true) def execute_command(command, raise_exception: true)
output = {} output = {
stdout = +'' stdout: +'',
stderr = +'' stderr: +'',
messages: [],
exit_code: 1, # default to error
}
try = 0 try = 0
begin begin
if try.nonzero? if try.nonzero?
request_new_id request_new_id
save save
end end
exit_code = 1 # default to error execution_time = attach_to_execution(command) do |socket, starting_time|
execution_time = attach_to_execution(command) do |socket|
socket.on :stderr do |data| 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 end
socket.on :stdout do |data| 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 end
socket.on :exit do |received_exit_code| socket.on :exit do |received_exit_code|
exit_code = received_exit_code output[:exit_code] = received_exit_code
end end
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 rescue Runner::Error::ExecutionTimeout => e
Rails.logger.debug { "Running command `#{command}` timed out: #{e.message}" } Rails.logger.debug { "Running command `#{command}` timed out: #{e.message}" }
output.merge!(status: :timeout, container_execution_time: e.execution_duration) 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) output.merge!(status: :failed, container_execution_time: e.execution_duration)
rescue Runner::Error => e rescue Runner::Error => e
Rails.logger.debug { "Running command `#{command}` failed: #{e.message}" } 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 ensure
# We forward the exception if requested # We forward the exception if requested
raise e if raise_exception && defined?(e) && e.present? 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
end end
@ -147,13 +154,13 @@ class Runner < ApplicationRecord
rescue Runner::Error rescue Runner::Error
# An additional error was raised during synchronization # An additional error was raised during synchronization
raise Runner::Error::EnvironmentNotFound.new( 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.' 'In addition, it could not be synced so that this probably indicates a permanent error.'
) )
else else
# No error was raised during synchronization # No error was raised during synchronization
raise Runner::Error::EnvironmentNotFound.new( 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.' 'It has been successfully synced now so that the next request should be successful.'
) )
end end

View File

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

View File

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

View File

@ -3,4 +3,22 @@
class Testrun < ApplicationRecord class Testrun < ApplicationRecord
belongs_to :file, class_name: 'CodeOcean::File', optional: true belongs_to :file, class_name: 'CodeOcean::File', optional: true
belongs_to :submission 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 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 ROLES = %w[admin teacher learner].freeze
belongs_to :consumer belongs_to :consumer
has_many :authentication_token, dependent: :destroy
has_many :study_group_memberships, as: :user has_many :study_group_memberships, as: :user
has_many :study_groups, through: :study_group_memberships, as: :user has_many :study_groups, through: :study_group_memberships, as: :user
has_many :exercises, as: :user has_many :exercises, as: :user
@ -40,4 +41,8 @@ class User < ApplicationRecord
def to_s def to_s
displayname displayname
end end
def self.ransackable_attributes(_auth_object = nil)
%w[name email external_id consumer_id role]
end
end end

View File

@ -5,7 +5,7 @@ class ExercisePolicy < AdminOrAuthorPolicy
admin? admin?
end 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? } define_method(action) { admin? || teacher_in_study_group? || (teacher? && @record.public?) || author? }
end end
@ -38,7 +38,13 @@ class ExercisePolicy < AdminOrAuthorPolicy
if @user.admin? if @user.admin?
@scope.all @scope.all
elsif @user.teacher? 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 else
@scope.none @scope.none
end end

View File

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

View File

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

View File

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

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