diff --git a/.github/workflows/test_ci.yml b/.github/workflows/test_ci.yml index 3a425ce1e6..f302b922e8 100644 --- a/.github/workflows/test_ci.yml +++ b/.github/workflows/test_ci.yml @@ -43,10 +43,49 @@ jobs: steps: - name: Checkout repo uses: actions/checkout@v4 - - name: Install system dependencies - run: | - sudo apt-get update - sudo apt-get -yqq install libpq-dev cmake ghostscript pandoc imagemagick libmagickwand-dev git libgl1 tesseract-ocr pandoc poppler-utils + - name: Install and cache system dependencies + uses: awalsh128/cache-apt-pkgs-action@latest + with: + packages: | + libpq-dev + cmake + ghostscript + pandoc + imagemagick + libmagickwand-dev + git + libgl1 + tesseract-ocr + pandoc + poppler-utils + fonts-liberation + libasound2 + libatk-bridge2.0-0 + libatk1.0-0 + libatspi2.0-0 + libcairo2 + libcups2 + libdbus-1-3 + libdrm2 + libegl1 + libgbm1 + libglib2.0-0 + libgtk-3-0 + libnspr4 + libnss3 + libpango-1.0-0 + libx11-6 + libx11-xcb1 + libxcb1 + libxcomposite1 + libxdamage1 + libxext6 + libxfixes3 + libxrandr2 + libxshmfence1 + version: 1.0 + # Packages 'fonts-liberation' and onward are needed for playwright's chromium installation. + # The list was taken from: https://github.com/microsoft/playwright/blob/main/packages/playwright-core/src/server/registry/nativeDeps.ts#L37-L63 - name: Set up ruby and cache gems uses: ruby/setup-ruby@v1 with: @@ -86,7 +125,6 @@ jobs: python3.10 -m venv venv ./venv/bin/pip install -r requirements-jupyter.txt -r requirements-scanner.txt ./venv/bin/playwright install chromium - ./venv/bin/playwright install-deps chromium - name: Configure server run: | sudo rm -f /etc/localtime diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7de877eb60..19f0d6c978 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 + rev: v6.0.0 hooks: - id: check-illegal-windows-names - id: check-json @@ -17,12 +17,12 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/rbubley/mirrors-prettier - rev: v3.6.2 + rev: v3.7.3 hooks: - id: prettier types_or: [javascript, jsx, css, scss, html] - repo: https://github.com/thibaudcolas/pre-commit-stylelint - rev: v16.21.1 + rev: v16.26.1 hooks: - id: stylelint additional_dependencies: [ @@ -39,7 +39,7 @@ repos: app/assets/stylesheets/common/_reset.scss )$ - repo: https://github.com/rubocop/rubocop - rev: v1.77.0 + rev: v1.81.7 hooks: - id: rubocop args: ["--autocorrect"] @@ -52,7 +52,7 @@ repos: Vagrantfile )$ additional_dependencies: - - rubocop-rails:2.27.0 + - rubocop-rails:2.33.4 - rubocop-performance:1.23.0 - rubocop-factory_bot:2.26.1 - rubocop-rspec:3.2.0 diff --git a/.rubocop.yml b/.rubocop.yml index 3241e00a86..68812dd020 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,10 +1,10 @@ require: - - rubocop-rails - rubocop-performance - rubocop-factory_bot - rubocop-rspec_rails - rubocop-capybara plugins: + - rubocop-rails - rubocop-rspec AllCops: @@ -15,7 +15,7 @@ AllCops: - 'Vagrantfile' NewCops: enable SuggestExtensions: false - TargetRubyVersion: 2.7 + TargetRubyVersion: 3.1 Capybara/ClickLinkOrButtonStyle: # TODO: Enable and fix errors after reviewing system tests Enabled: false @@ -187,6 +187,9 @@ Rails/RequireDependency: Rails/SkipsModelValidations: Enabled: false +Rails/StrongParametersExpect: # TODO: Enable this one and fix all issues + Enabled: false + Rails/UniqueValidationWithoutIndex: Enabled: false diff --git a/Changelog.md b/Changelog.md index ccfddbec05..9376e000f6 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,46 @@ # Changelog +## [v2.9.0] + +### ✨ New features and improvements +- Added touch event support for PDF and image annotations in grading view (#7736) +- Added datetime-based visibility scheduling for assessments with `visible_on` and `visible_until` columns (#7697) +- Added frontend UI for assignment visibility scheduling with three visibility options and section-specific overrides (#7717) +- Added new loading spinner icon for tables (#7602) +- Added functionality to apply bonuses and penalties as a percentage of the student's earned marks to ExtraMark model (#7702) +- Switched to consistent Font Awesome chevrons for expander icons (#7713) +- Install Ruby-LSP to allow development inside different IDEs such as VSCode (#7718) +- Ensure only instructors and admins can link course, as LMS launch MarkUs button made available for all users (#7714) +- Include student number in roster sync from Canvas (#7731) +- Add API endpoint `add_test_run` that allows independent user submissions of test executions to MarkUs (#7730) +- Display timeout status for autotest runs in the Test Results table. (#7734) +- Assign extra marks in test definition. (Currently limited to pytest files) (#7728) +- Enable zip downloads of test results (#7733) +- Create rake task to remove orphaned end users (#7741) +- Enable scanned assignments the ability to add inactive students (#7737) + +### πŸ› Bug fixes +- Fix name column search in graders table (#7693) +- Check against mtime instead of atime for clean up of git repo directories in tmp folder (#7706) +- Update Model: Fix level validation checks through use of a custom validator (#7696) +- Fixed test group results table to display `extra_info` field from all test groups (#7710) +- Fixed syncing grades with Canvas to not include inactive students (#7759) + +### πŸ”§ Internal changes +- Updated Github Actions CI to use cache-apt-pkgs to speed up workflow runs (#7645) +- Converted "Create Group" functionality to React modal (#7663) +- Added tests to improve coverage for `AnnotationCategory`'s `self.to_json` method +- Added tests to the Criteria Controller class to achieve full test coverage +- Refactored Criterion subclasses to remove redundant code +- Converted "Rename Group" functionality to React modal (#7673) +- Fixed Rack deprecation warnings by updating HTTP status code symbols (#7675) +- Refactored "Reuse Groups" functionality to use React modal and relocated button to action box row (#7688) +- Updated pre-commit `rubocop-rails` version to 2.33.4 (#7691) +- Refactored MarksPanel child components and converted the components into hook-based function components +- Refactored jQuery active marks panel component tracking logic into React +- Updated the course summary table to use `@tanstack/react-table` v8 (#7732) +- Refactored `test_run_table.jsx` by extracting nested components into separate files (#7739) + ## [v2.8.2] ### ✨ New features and improvements @@ -27,11 +68,10 @@ ### πŸ”§ Internal changes - Updated the assignment summary table to use `@tanstack/react-table` v8 (#7630) +- Updated Github Actions CI to use cache-apt-pkgs to speed up workflow runs (#7645) ## [v2.8.0] -### 🚨 Breaking changes - ### ✨ New features and improvements - Improved layout and labeling in the assignment settings form for both standard and timed assessments. (#7531) - Design improvement of tables when the data is empty. (#7557) diff --git a/Gemfile b/Gemfile index 508913d260..6a066f2b82 100644 --- a/Gemfile +++ b/Gemfile @@ -8,7 +8,7 @@ source 'https://rubygems.org' # Bundler requires these gems in all environments gem 'puma' -gem 'rails', '~> 8.0.2.1' +gem 'rails', '~> 8.0.3' gem 'sprockets' gem 'sprockets-rails' @@ -37,7 +37,7 @@ gem 'histogram' # Internationalization gem 'i18n' gem 'i18n-js' -gem 'rails-i18n', '~> 8.0.1' +gem 'rails-i18n', '~> 8.0.2' # Redis gem 'redis', '~> 5.4.1' @@ -46,7 +46,7 @@ gem 'redis', '~> 5.4.1' gem 'combine_pdf' gem 'prawn' gem 'prawn-qrcode' -gem 'rmagick', '~> 6.1.2' +gem 'rmagick', '~> 6.1.4' gem 'rtesseract' # Ruby miscellany @@ -85,6 +85,10 @@ group :development do gem 'listen' # to listen for changes in i18n-js files end +group :development_extra, optional: true do + gem 'ruby-lsp', require: false +end + group :test do gem 'factory_bot_rails' gem 'fuubar' @@ -106,7 +110,7 @@ group :development, :test do gem 'capybara' gem 'debug', '>= 1.0.0' gem 'i18n-tasks', require: false - gem 'rspec-rails', '~> 8.0.1' + gem 'rspec-rails', '~> 8.0.2' gem 'selenium-webdriver' end diff --git a/Gemfile.lock b/Gemfile.lock index 10666f9f2a..e85dbeb45d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,29 +3,29 @@ GEM specs: action_policy (0.7.5) ruby-next-core (>= 1.0) - actioncable (8.0.2.1) - actionpack (= 8.0.2.1) - activesupport (= 8.0.2.1) + actioncable (8.0.3) + actionpack (= 8.0.3) + activesupport (= 8.0.3) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (8.0.2.1) - actionpack (= 8.0.2.1) - activejob (= 8.0.2.1) - activerecord (= 8.0.2.1) - activestorage (= 8.0.2.1) - activesupport (= 8.0.2.1) + actionmailbox (8.0.3) + actionpack (= 8.0.3) + activejob (= 8.0.3) + activerecord (= 8.0.3) + activestorage (= 8.0.3) + activesupport (= 8.0.3) mail (>= 2.8.0) - actionmailer (8.0.2.1) - actionpack (= 8.0.2.1) - actionview (= 8.0.2.1) - activejob (= 8.0.2.1) - activesupport (= 8.0.2.1) + actionmailer (8.0.3) + actionpack (= 8.0.3) + actionview (= 8.0.3) + activejob (= 8.0.3) + activesupport (= 8.0.3) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (8.0.2.1) - actionview (= 8.0.2.1) - activesupport (= 8.0.2.1) + actionpack (8.0.3) + actionview (= 8.0.3) + activesupport (= 8.0.3) nokogiri (>= 1.8.5) rack (>= 2.2.4) rack-session (>= 1.0.1) @@ -33,42 +33,42 @@ GEM rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (8.0.2.1) - actionpack (= 8.0.2.1) - activerecord (= 8.0.2.1) - activestorage (= 8.0.2.1) - activesupport (= 8.0.2.1) + actiontext (8.0.3) + actionpack (= 8.0.3) + activerecord (= 8.0.3) + activestorage (= 8.0.3) + activesupport (= 8.0.3) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (8.0.2.1) - activesupport (= 8.0.2.1) + actionview (8.0.3) + activesupport (= 8.0.3) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (8.0.2.1) - activesupport (= 8.0.2.1) + activejob (8.0.3) + activesupport (= 8.0.3) globalid (>= 0.3.6) activejob-status (1.0.1) activejob (>= 6.0) activesupport (>= 6.0) - activemodel (8.0.2.1) - activesupport (= 8.0.2.1) + activemodel (8.0.3) + activesupport (= 8.0.3) activemodel-serializers-xml (1.0.3) activemodel (>= 5.0.0.a) activesupport (>= 5.0.0.a) builder (~> 3.1) - activerecord (8.0.2.1) - activemodel (= 8.0.2.1) - activesupport (= 8.0.2.1) + activerecord (8.0.3) + activemodel (= 8.0.3) + activesupport (= 8.0.3) timeout (>= 0.4.0) - activestorage (8.0.2.1) - actionpack (= 8.0.2.1) - activejob (= 8.0.2.1) - activerecord (= 8.0.2.1) - activesupport (= 8.0.2.1) + activestorage (8.0.3) + actionpack (= 8.0.3) + activejob (= 8.0.3) + activerecord (= 8.0.3) + activesupport (= 8.0.3) marcel (~> 1.0) - activesupport (8.0.2.1) + activesupport (8.0.3) base64 benchmark (>= 0.3) bigdecimal @@ -88,12 +88,12 @@ GEM execjs (~> 2) awesome_print (1.9.2) base64 (0.3.0) - benchmark (0.4.1) + benchmark (0.5.0) better_errors (2.10.1) erubi (>= 1.0.0) rack (>= 0.9.0) rouge (>= 1.0.0) - bigdecimal (3.2.2) + bigdecimal (3.3.1) binding_of_caller (1.0.1) debug_inspector (>= 1.2.0) bootsnap (1.18.6) @@ -102,7 +102,7 @@ GEM racc browser (6.2.0) builder (3.3.0) - bullet (8.0.8) + bullet (8.1.0) activesupport (>= 3.0.0) uniform_notifier (~> 1.11) capybara (3.40.0) @@ -122,10 +122,10 @@ GEM config (5.6.1) deep_merge (~> 1.2, >= 1.2.1) ostruct - connection_pool (2.5.3) + connection_pool (2.5.4) cookies_eu (1.7.8) js_cookie_rails (~> 2.2.0) - crack (1.0.0) + crack (1.0.1) bigdecimal rexml crass (1.0.6) @@ -177,11 +177,11 @@ GEM dry-initializer (~> 3.2) dry-schema (~> 1.14) zeitwerk (~> 2.6) - erb (5.0.1) + erb (5.0.2) erubi (1.13.1) et-orbi (1.2.11) tzinfo - exception_notification (5.0.0) + exception_notification (5.0.1) actionmailer (>= 7.1, < 9) activesupport (>= 7.1, < 9) execjs (2.10.0) @@ -200,9 +200,9 @@ GEM rspec-core (~> 3.0) ruby-progressbar (~> 1.4) glob (0.4.0) - globalid (1.2.1) + globalid (1.3.0) activesupport (>= 6.1) - hashdiff (1.2.0) + hashdiff (1.2.1) highline (3.1.2) reline histogram (0.2.4.1) @@ -222,7 +222,7 @@ GEM rainbow (>= 2.2.2, < 4.0) ruby-progressbar (~> 1.8, >= 1.8.1) terminal-table (>= 1.5.1) - io-console (0.8.0) + io-console (0.8.1) irb (1.15.2) pp (>= 0.6.0) rdoc (>= 4.0.0) @@ -234,10 +234,11 @@ GEM railties (>= 3.1) jsbundling-rails (1.3.1) railties (>= 6.0.0) - json (2.13.2) + json (2.16.0) jwt (2.10.1) base64 kgio (2.11.4) + language_server-protocol (3.17.0.5) listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) @@ -251,17 +252,17 @@ GEM net-imap net-pop net-smtp - marcel (1.0.4) + marcel (1.1.0) matrix (0.4.2) mini_mime (1.1.5) mini_portile2 (2.8.9) - minitest (5.25.5) + minitest (5.26.0) mono_logger (1.1.2) msgpack (1.8.0) multi_json (1.15.0) mustermann (3.0.4) ruby2_keywords (~> 0.0.1) - net-imap (0.5.8) + net-imap (0.5.11) date net-protocol net-pop (0.1.2) @@ -270,8 +271,8 @@ GEM timeout net-smtp (0.5.1) net-protocol - nio4r (2.7.4) - nokogiri (1.18.9) + nio4r (2.7.5) + nokogiri (1.18.10) mini_portile2 (~> 2.8.2) racc (~> 1.4) observer (0.1.2) @@ -280,8 +281,8 @@ GEM ast (~> 2.4.1) racc pdf-core (0.10.0) - pg (1.6.0) - pkg-config (1.6.2) + pg (1.6.2) + pkg-config (1.6.5) pluck_to_hash (1.0.2) activerecord (>= 4.0.2) activesupport (>= 4.0.2) @@ -295,11 +296,12 @@ GEM prawn (>= 1) rqrcode (>= 1.0.0) prettyprint (0.2.0) + prism (1.6.0) psych (5.2.6) date stringio public_suffix (6.0.2) - puma (6.6.1) + puma (7.1.0) nio4r (~> 2.0) raabro (1.4.0) racc (1.8.1) @@ -318,20 +320,20 @@ GEM rack (>= 1.3) rackup (2.2.1) rack (>= 3) - rails (8.0.2.1) - actioncable (= 8.0.2.1) - actionmailbox (= 8.0.2.1) - actionmailer (= 8.0.2.1) - actionpack (= 8.0.2.1) - actiontext (= 8.0.2.1) - actionview (= 8.0.2.1) - activejob (= 8.0.2.1) - activemodel (= 8.0.2.1) - activerecord (= 8.0.2.1) - activestorage (= 8.0.2.1) - activesupport (= 8.0.2.1) + rails (8.0.3) + actioncable (= 8.0.3) + actionmailbox (= 8.0.3) + actionmailer (= 8.0.3) + actionpack (= 8.0.3) + actiontext (= 8.0.3) + actionview (= 8.0.3) + activejob (= 8.0.3) + activemodel (= 8.0.3) + activerecord (= 8.0.3) + activestorage (= 8.0.3) + activesupport (= 8.0.3) bundler (>= 1.15.0) - railties (= 8.0.2.1) + railties (= 8.0.3) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) @@ -343,20 +345,21 @@ GEM rails-html-sanitizer (1.6.2) loofah (~> 2.21) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - rails-i18n (8.0.1) + rails-i18n (8.0.2) i18n (>= 0.7, < 2) railties (>= 8.0.0, < 9) rails_performance (1.4.2) browser railties redis - railties (8.0.2.1) - actionpack (= 8.0.2.1) - activesupport (= 8.0.2.1) + railties (8.0.3) + actionpack (= 8.0.3) + activesupport (= 8.0.3) irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) zeitwerk (~> 2.6) rainbow (3.1.1) raindrops (0.20.0) @@ -364,6 +367,8 @@ GEM rb-fsevent (0.11.2) rb-inotify (0.10.1) ffi (~> 1.0) + rbs (3.9.5) + logger rdoc (6.14.2) erb psych (>= 4.0.0) @@ -375,7 +380,7 @@ GEM redis-namespace (1.11.0) redis (>= 4) regexp_parser (2.9.0) - reline (0.6.1) + reline (0.6.2) io-console (~> 0.5) responders (3.1.1) actionpack (>= 5.2) @@ -390,8 +395,8 @@ GEM redis (>= 3.3) resque (>= 1.27) rufus-scheduler (~> 3.2, != 3.3) - rexml (3.4.1) - rmagick (6.1.2) + rexml (3.4.4) + rmagick (6.1.4) observer (~> 0.1) pkg-config (~> 1.4) rouge (4.1.3) @@ -407,7 +412,7 @@ GEM rspec-mocks (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-rails (8.0.1) + rspec-rails (8.0.2) actionpack (>= 7.2) activesupport (>= 7.2) railties (>= 7.2) @@ -415,22 +420,26 @@ GEM rspec-expectations (~> 3.13) rspec-mocks (~> 3.13) rspec-support (~> 3.13) - rspec-support (3.13.4) + rspec-support (3.13.5) rtesseract (3.1.4) + ruby-lsp (0.26.2) + language_server-protocol (~> 3.17.0) + prism (>= 1.2, < 2.0) + rbs (>= 3, < 5) ruby-next-core (1.1.1) ruby-progressbar (1.13.0) ruby-rc4 (0.1.5) ruby2_keywords (0.0.5) - rubyzip (2.4.1) + rubyzip (3.0.2) rufus-scheduler (3.9.2) fugit (~> 1.1, >= 1.11.1) rugged (1.9.0) securerandom (0.4.1) - selenium-webdriver (4.34.0) + selenium-webdriver (4.35.0) base64 (~> 0.2) logger (~> 1.4) rexml (~> 3.2, >= 3.2.5) - rubyzip (>= 1.2.2, < 3.0) + rubyzip (>= 1.2.2, < 4.0) websocket (~> 1.0) shoulda-callback-matchers (1.1.4) activesupport (>= 3) @@ -442,7 +451,7 @@ GEM simplecov-html (~> 0.11) simplecov_json_formatter (~> 0.1) simplecov-html (0.12.3) - simplecov-lcov (0.8.0) + simplecov-lcov (0.9.0) simplecov_json_formatter (0.1.4) sinatra (4.2.0) logger (>= 1.6.0) @@ -469,6 +478,7 @@ GEM tilt (2.6.1) timecop (0.9.10) timeout (0.4.3) + tsort (0.2.0) ttfunk (1.8.0) bigdecimal (~> 3.1) tzinfo (2.0.6) @@ -479,10 +489,10 @@ GEM unicorn (6.1.0) kgio (~> 2.6) raindrops (~> 0.7) - uniform_notifier (1.17.0) - uri (1.0.3) + uniform_notifier (1.18.0) + uri (1.1.0) useragent (0.16.11) - webmock (3.25.1) + webmock (3.26.1) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) @@ -540,19 +550,20 @@ DEPENDENCIES prawn-qrcode puma rack-cors - rails (~> 8.0.2.1) + rails (~> 8.0.3) rails-controller-testing rails-html-sanitizer - rails-i18n (~> 8.0.1) + rails-i18n (~> 8.0.2) rails_performance redcarpet redis (~> 5.4.1) responders resque resque-scheduler - rmagick (~> 6.1.2) - rspec-rails (~> 8.0.1) + rmagick (~> 6.1.4) + rspec-rails (~> 8.0.2) rtesseract + ruby-lsp rubyzip rugged selenium-webdriver diff --git a/app/MARKUS_VERSION b/app/MARKUS_VERSION index 2dc78ad971..ceb1a8f879 100644 --- a/app/MARKUS_VERSION +++ b/app/MARKUS_VERSION @@ -1 +1 @@ -VERSION=v2.8.2,PATCH_LEVEL=DEV +VERSION=v2.9.0,PATCH_LEVEL=DEV diff --git a/app/assets/javascripts/Annotations/image_annotation_manager.js b/app/assets/javascripts/Annotations/image_annotation_manager.js index 40ed7baf3d..b4cf57b6eb 100644 --- a/app/assets/javascripts/Annotations/image_annotation_manager.js +++ b/app/assets/javascripts/Annotations/image_annotation_manager.js @@ -121,6 +121,9 @@ class ImageAnnotationManager extends AnnotationManager { }; document.getElementById("image_container").onmousemove = this.render_holders.bind(this); + + // Touch event handlers + this.image_preview.addEventListener("touchstart", this.start_select_box_touch.bind(this)); } } @@ -174,35 +177,41 @@ class ImageAnnotationManager extends AnnotationManager { this.sel_box.style.display = "block"; this.sel_box.style.left = xy_coords[0] + "px"; this.sel_box.style.top = xy_coords[1] + "px"; - this.sel_box.onmousemove = this.mouse_move.bind(this); - this.sel_box.onmouseup = this.stop_select_box.bind(this); - this.image_preview.onmousemove = this.mouse_move.bind(this); - this.image_preview.onmouseup = this.stop_select_box.bind(this); + // Bind handlers for tracking mouse movement + this.bound_mouse_move = this.mouse_move.bind(this); + this.bound_stop_select_box = this.stop_select_box.bind(this); + + this.sel_box.addEventListener("mousemove", this.bound_mouse_move); + this.sel_box.addEventListener("mouseup", this.bound_stop_select_box); + this.image_preview.addEventListener("mousemove", this.bound_mouse_move); + this.image_preview.addEventListener("mouseup", this.bound_stop_select_box); for (let annotation_id in this.annotations) { let holder = document.getElementById("annotation_holder_" + annotation_id); - holder.onmousemove = this.mouse_move.bind(this); - holder.onmouseup = this.stop_select_box.bind(this); - holder.onmousedown = null; + holder.removeEventListener("mousedown", this.bound_start_select_box); + holder.addEventListener("mousemove", this.bound_mouse_move); + holder.addEventListener("mouseup", this.bound_stop_select_box); } } // Stop tracking the mouse and open up modal to create an image annotation. stop_select_box() { - this.image_preview.onmousemove = this.check_for_annotations.bind(this); - this.image_preview.onmouseup = null; - this.image_preview.onmousemove = null; - - this.sel_box.onmousemove = null; - this.sel_box.onmouseup = null; + this.sel_box.removeEventListener("mousemove", this.bound_mouse_move); + this.sel_box.removeEventListener("mouseup", this.bound_stop_select_box); + this.image_preview.removeEventListener("mousemove", this.bound_mouse_move); + this.image_preview.removeEventListener("mouseup", this.bound_stop_select_box); for (let annotation_id in this.annotations) { let holder = document.getElementById("annotation_holder_" + annotation_id); - holder.onmousemove = this.check_for_annotations.bind(this); - holder.onmousedown = this.start_select_box.bind(this); - holder.onmouseup = null; - holder.onmouseleave = this.check_for_annotations.bind(this); + holder.removeEventListener("mousemove", this.bound_mouse_move); + holder.removeEventListener("mouseup", this.bound_stop_select_box); + + // Re-bind the start handler + if (!this.bound_start_select_box) { + this.bound_start_select_box = this.start_select_box.bind(this); + } + holder.addEventListener("mousedown", this.bound_start_select_box); } } @@ -222,6 +231,77 @@ class ImageAnnotationManager extends AnnotationManager { this.sel_box.style.height = Math.abs(xy_coords[1] - this.sel_box.orig_y) + "px"; } + /** + * Touch event handlers for creating annotations + */ + + // Start tracking the touch to create an annotation. + start_select_box_touch(e) { + // Prevent default to avoid scrolling while annotating + e.preventDefault(); + + this.hide_image_annotations(); + this.hide_selection_box(); + let touch = e.touches[0]; + let xy_coords = get_absolute_cursor_pos_touch(touch); + + this.sel_box.orig_x = xy_coords[0]; + this.sel_box.orig_y = xy_coords[1]; + this.sel_box.style.display = "block"; + this.sel_box.style.left = xy_coords[0] + "px"; + this.sel_box.style.top = xy_coords[1] + "px"; + + // Bind handlers for tracking touch movement + this.bound_touch_move = this.touch_move.bind(this); + this.bound_stop_select_box_touch = this.stop_select_box_touch.bind(this); + + this.sel_box.addEventListener("touchmove", this.bound_touch_move); + this.sel_box.addEventListener("touchend", this.bound_stop_select_box_touch); + this.image_preview.addEventListener("touchmove", this.bound_touch_move); + this.image_preview.addEventListener("touchend", this.bound_stop_select_box_touch); + + for (let annotation_id in this.annotations) { + let holder = document.getElementById("annotation_holder_" + annotation_id); + holder.removeEventListener("touchstart", this.bound_start_select_box_touch); + holder.addEventListener("touchmove", this.bound_touch_move); + holder.addEventListener("touchend", this.bound_stop_select_box_touch); + } + } + + // Stop tracking the touch and open up modal to create an image annotation. + stop_select_box_touch() { + this.sel_box.removeEventListener("touchmove", this.bound_touch_move); + this.sel_box.removeEventListener("touchend", this.bound_stop_select_box_touch); + this.image_preview.removeEventListener("touchmove", this.bound_touch_move); + this.image_preview.removeEventListener("touchend", this.bound_stop_select_box_touch); + + for (let annotation_id in this.annotations) { + let holder = document.getElementById("annotation_holder_" + annotation_id); + holder.removeEventListener("touchmove", this.bound_touch_move); + holder.removeEventListener("touchend", this.bound_stop_select_box_touch); + + // Re-bind the start handler + if (!this.bound_start_select_box_touch) { + this.bound_start_select_box_touch = this.start_select_box_touch.bind(this); + } + holder.addEventListener("touchstart", this.bound_start_select_box_touch); + } + } + + // Draw red selection outline for touch + touch_move(e) { + // Prevent default to avoid scrolling while annotating + e.preventDefault(); + + let touch = e.touches[0]; + let xy_coords = get_absolute_cursor_pos_touch(touch); + + this.sel_box.style.left = Math.min(xy_coords[0], this.sel_box.orig_x) + "px"; + this.sel_box.style.width = Math.abs(xy_coords[0] - this.sel_box.orig_x) + "px"; + this.sel_box.style.top = Math.min(xy_coords[1], this.sel_box.orig_y) + "px"; + this.sel_box.style.height = Math.abs(xy_coords[1] - this.sel_box.orig_y) + "px"; + } + getSelection(warn_no_selection = true) { let box = this.get_selection_box_coordinates(); if (!box) { @@ -325,3 +405,27 @@ function get_absolute_cursor_pos(e) { return [posx, posy]; } + +// Get the coordinates of a touch event relative to image container +// and return them in an array of the form [x, y]. +function get_absolute_cursor_pos_touch(touch) { + let posx = 0; + let posy = 0; + + if (!touch) return [0, 0]; + + if (touch.pageX || touch.pageY) { + posx = touch.pageX; + posy = touch.pageY; + } else if (touch.clientX || touch.clientY) { + posx = touch.clientX + document.body.scrollLeft + document.documentElement.scrollLeft; + posy = touch.clientY + document.body.scrollTop + document.documentElement.scrollTop; + } + + let image_container = document.getElementById("image_container"); + let codeviewer = document.getElementById("codeviewer"); + posx -= image_container.offsetLeft - codeviewer.scrollLeft; + posy -= image_container.offsetTop - codeviewer.scrollTop; + + return [posx, posy]; +} diff --git a/app/assets/javascripts/Annotations/pdf_annotation_manager.js b/app/assets/javascripts/Annotations/pdf_annotation_manager.js index a5dee80421..e3334b727e 100644 --- a/app/assets/javascripts/Annotations/pdf_annotation_manager.js +++ b/app/assets/javascripts/Annotations/pdf_annotation_manager.js @@ -230,7 +230,7 @@ let annotation_text_displayer = this.annotation_text_displayer; $control.onmousemove = function (ev) { - let point = getRelativePointForMouseEvent(ev, $page, -1); + let point = getRelativePointForEvent(ev, $page, -1); annotation_text_displayer.setDisplayNodeParent($page[0]); annotation_text_displayer.displayCollection( @@ -278,15 +278,9 @@ y: 0, }; - // Press down, activate the selection box - $pages.mousedown(ev => { - if (ev.which !== 1 && ev.target.id === "sel_box") { - return; - } - - let point = getRelativePointForMouseEvent(ev); - - this.setSelectionBox($(ev.delegateTarget), { + // Helper: Start selection box + const startSelection = (point, $target) => { + this.setSelectionBox($target, { x: point.x, y: point.y, width: 0, @@ -296,25 +290,24 @@ start = point; selectionBoxActive = true; - }); + }; - // Change the selection box - $pages.mousemove(ev => { + // Helper: Update selection box dimensions + const updateSelection = (point, $target) => { if (!selectionBoxActive) { return; } - let point = getRelativePointForMouseEvent(ev); // Mouse position - this.setSelectionBox($(ev.delegateTarget), { + this.setSelectionBox($target, { x: Math.min(start.x, point.x), y: Math.min(start.y, point.y), width: Math.abs(start.x - point.x), height: Math.abs(start.y - point.y), }); - }); + }; - // Finish the selection box - $pages.mouseup(() => { + // Helper: End selection box + const endSelection = () => { let size = this.selectionBoxSize(); // If the box is REALLY small then hide it @@ -323,28 +316,69 @@ } selectionBoxActive = false; + }; + + // Mouse event handlers + $pages.mousedown(ev => { + if (ev.which !== 1 && ev.target.id === "sel_box") { + return; + } + + let point = getRelativePointForEvent(ev); + startSelection(point, $(ev.delegateTarget)); + }); + + $pages.mousemove(ev => { + let point = getRelativePointForEvent(ev); + updateSelection(point, $(ev.delegateTarget)); + }); + + $pages.mouseup(() => { + endSelection(); + }); + + // Touch event handlers + $pages.on("touchstart", ev => { + // Prevent default to avoid scrolling while annotating + ev.preventDefault(); + + let touch = ev.originalEvent.touches[0]; + let point = getRelativePointForEvent(touch, ev.delegateTarget, undefined); + startSelection(point, $(ev.delegateTarget)); + }); + + $pages.on("touchmove", ev => { + // Prevent default to avoid scrolling while annotating + ev.preventDefault(); + + let touch = ev.originalEvent.touches[0]; + let point = getRelativePointForEvent(touch, ev.delegateTarget, undefined); + updateSelection(point, $(ev.delegateTarget)); + }); + + $pages.on("touchend", () => { + endSelection(); }); } } /** - * Returns the selection point in percentage units for an event on - * a element. + * Returns the selection point in percentage units for a mouse or touch event. * - * @param {Event} ev The event that occurred. - * @param {String|DOMNode|jQuery} relativeTo The element to calculate the offset for. - * @param {number} mouseOffset Custom mouse offset value. + * @param {Event|Touch} eventOrTouch The event or touch object. + * @param {String|DOMNode|jQuery} relativeTo The element to calculate the offset for. + * @param {number} mouseOffset Custom mouse offset value. * @return {{x: number, y:number}} The relative point in the element the event occurred in. */ - function getRelativePointForMouseEvent(ev, relativeTo, mouseOffset) { - let $elem = relativeTo ? $(relativeTo) : $(ev.delegateTarget); + function getRelativePointForEvent(eventOrTouch, relativeTo, mouseOffset) { + let $elem = relativeTo ? $(relativeTo) : $(eventOrTouch.delegateTarget); let offset = $elem.offset(); let width = $elem.width(); let height = $elem.height(); - let x = ev.pageX - offset.left - (mouseOffset || MOUSE_OFFSET); - let y = ev.pageY - offset.top - (mouseOffset || MOUSE_OFFSET); + let x = eventOrTouch.pageX - offset.left - (mouseOffset || MOUSE_OFFSET); + let y = eventOrTouch.pageY - offset.top - (mouseOffset || MOUSE_OFFSET); return { x: 1 - (width - x) / width, diff --git a/app/assets/javascripts/Grader/marking.js b/app/assets/javascripts/Grader/marking.js index d8d8227994..e7e28002e0 100644 --- a/app/assets/javascripts/Grader/marking.js +++ b/app/assets/javascripts/Grader/marking.js @@ -24,71 +24,3 @@ domContentLoadedCB(); } })(); - -// designate $next_criteria as the currently selected criteria -function activeCriterion($next_criteria) { - if (!$next_criteria.hasClass("active-criterion")) { - const $criteria_list = $(".marks-list > li"); - // remove all previous active-criterion (there should only be one) - $criteria_list.removeClass("active-criterion"); - // scroll the $next_criteria to the top of the criterion bar - $("#mark_viewer").animate( - { - scrollTop: $next_criteria.offset().top - $criteria_list.first().offset().top, - }, - 100 - ); - $next_criteria.addClass("active-criterion"); - // Unfocus any exisiting textfields/radio buttons - $(".flexible_criterion input, .checkbox_criterion input").blur(); - // Remove any active rubrics - $(".active-rubric").removeClass("active-rubric"); - if ($next_criteria.hasClass("flexible_criterion")) { - var $input = $next_criteria.find('input[type="text"]'); - // This step is necessary for focusing the cursor at the end of input - $input.focus().val($input.val()); - } else if ($next_criteria.hasClass("rubric_criterion")) { - $selected = $next_criteria.find(".rubric-level.selected"); - if ($selected.length) { - $selected.addClass("active-rubric"); - } else { - $next_criteria.find("tr>td")[0].addClass("active-rubric"); - } - } else if ($next_criteria.hasClass("checkbox_criterion")) { - $selected_option = $next_criteria.find("input[checked]")[0]; - if ($selected_option) { - $selected_option.focus(); - } else { - $next_criteria.find("input")[0].focus(); - } - } - // If this current criteria is not expanded, expand it - if (!$next_criteria.hasClass("expanded")) { - if ($next_criteria.hasClass("rubric_criterion")) { - $next_criteria.children(".criterion_title").click(); - } else { - $next_criteria.find(".criterion_expand").click(); - } - } - } -} - -// Set the active-criterion to the next sibling -function nextCriterion() { - $next_criterion = $(".active-criterion").next("li:not(.unassigned)"); - // If no next criterion exists, loop back to the first one - if (!$next_criterion.length) { - $next_criterion = $(".marks-list > li:not(.unassigned)").first(); - } - activeCriterion($next_criterion); -} - -// Set the active-criterion to the previous sibling -function prevCriterion() { - $prev_criterion = $(".active-criterion").prev("li:not(.unassigned)"); - // If no previous criterion exists, loop back to the last one - if (!$prev_criterion.length) { - $prev_criterion = $(".marks-list > li:not(.unassigned)").last(); - } - activeCriterion($prev_criterion); -} diff --git a/app/assets/javascripts/Groups/index.js b/app/assets/javascripts/Groups/index.js index 2a518483f0..7c9e34a462 100644 --- a/app/assets/javascripts/Groups/index.js +++ b/app/assets/javascripts/Groups/index.js @@ -1,13 +1,8 @@ -var modalCreate, - modalNotesGroup, - modalAssignmentGroupReUse = null; +var modalCreate, modalNotesGroup; (function () { const domContentLoadedCB = function () { - window.modal_rename = new ModalMarkus("#rename_group_dialog"); - modalCreate = new ModalMarkus("#create_group_dialog"); modalNotesGroup = new ModalMarkus("#notes_dialog"); - modalAssignmentGroupReUse = new ModalMarkus("#assignment_group_use_dialog"); }; if (document.readyState === "loading") { diff --git a/app/assets/javascripts/create_assignment.js b/app/assets/javascripts/create_assignment.js index c6ff519ed3..70107d9e84 100644 --- a/app/assets/javascripts/create_assignment.js +++ b/app/assets/javascripts/create_assignment.js @@ -157,6 +157,10 @@ function change_submission_rule() { "disabled", "disabled" ); + + $(".penalty-type-selector").hide(); + $(".penalty-type-selector select").prop("disabled", true); + if ($("#grace_period_submission_rule").is(":checked")) { $("#grace_periods").show(); $("#grace_periods input").prop("disabled", ""); @@ -164,9 +168,38 @@ function change_submission_rule() { if ($("#penalty_decay_period_submission_rule").is(":checked")) { $("#penalty_decay_periods").show(); $("#penalty_decay_periods input").prop("disabled", ""); + $("#penalty_type_selector_decay").show(); + $("#penalty_type_selector_decay select").prop("disabled", ""); + $("#penalty_decay_periods .deduction-unit").text( + $("#penalty_type_selector_decay select").val() === "points" + ? I18n.t("submission_rules.deduction_unit.marks") + : I18n.t("submission_rules.deduction_unit.percentage") + ); } if ($("#penalty_period_submission_rule").is(":checked")) { $("#penalty_periods").show(); $("#penalty_periods input").prop("disabled", ""); + $("#penalty_type_selector_period").show(); + $("#penalty_type_selector_period select").prop("disabled", ""); + $("#penalty_periods .deduction-unit").text( + $("#penalty_type_selector_period select").val() === "points" + ? I18n.t("submission_rules.deduction_unit.marks") + : I18n.t("submission_rules.deduction_unit.percentage") + ); } + $("#penalty_type_selector_decay select, #penalty_type_selector_period select").on( + "change", + function (event) { + var isDecay = + $(event.target).closest(".penalty-type-selector").attr("id") === + "penalty_type_selector_decay"; + var selector = isDecay ? "#penalty_decay_periods" : "#penalty_periods"; + + if ($(event.target).val() === "points") { + $(selector + " .deduction-unit").text(I18n.t("submission_rules.deduction_unit.marks")); + } else { + $(selector + " .deduction-unit").text(I18n.t("submission_rules.deduction_unit.percentage")); + } + } + ); } diff --git a/app/assets/stylesheets/common/_markus.scss b/app/assets/stylesheets/common/_markus.scss index 7867e82139..5811ba5455 100644 --- a/app/assets/stylesheets/common/_markus.scss +++ b/app/assets/stylesheets/common/_markus.scss @@ -878,6 +878,7 @@ ul.tags { } .ReactModal__Overlay--after-open { + background-color: rgba(0, 0, 0, 0.75) !important; z-index: 100; } @@ -923,6 +924,10 @@ ul.tags { height: 10em; max-height: 10em; } + + form label { + margin-right: 0.3em; + } } /** Menus */ @@ -1003,24 +1008,12 @@ nav { } /** Dropdown menu */ -.arrow-down { - border-left: 7.5px solid transparent; - border-right: 7.5px solid transparent; - border-top: 7.5px solid $primary-one; - float: right; - height: 0; - margin-top: 4px; - width: 0; -} - +.arrow-down, .arrow-up { - border-bottom: 7.5px solid $primary-one; - border-left: 7.5px solid transparent; - border-right: 7.5px solid transparent; + color: $primary-one; float: right; - height: 0; - margin-top: 4px; - width: 0; + margin-left: 5px; + margin-top: 1px; } .dropdown { @@ -1095,11 +1088,6 @@ nav { text-transform: uppercase; } } - - .arrow-down, - .arrow-up { - margin-left: 5px; - } } .nested-submenu { diff --git a/app/assets/stylesheets/common/_table.scss b/app/assets/stylesheets/common/_table.scss index 17aff5af9e..624f208e45 100644 --- a/app/assets/stylesheets/common/_table.scss +++ b/app/assets/stylesheets/common/_table.scss @@ -237,30 +237,14 @@ padding-right: 22px; } + .rt-expandable { + text-align: center; + } + .rt-expander { display: inline-block; position: relative; margin: 0 10px; - color: transparent; - - &::after { - content: ''; - position: absolute; - width: 0; - height: 0; - top: 50%; - left: 50%; - transform: translate(-50%, -50%) rotate(-90deg); - border-left: 5.04px solid transparent; - border-right: 5.04px solid transparent; - border-top: 7px solid $sharp-line; - transition: all 0.3s var(--easeOutBack); - cursor: pointer; - } - - &.-open::after { - transform: translate(-50%, -50%) rotate(0deg); - } } .rt-resizer { @@ -453,7 +437,8 @@ cursor: default; } - .rt-expander { + .rt-expander, + .rt-expander-custom { display: none; } } @@ -520,21 +505,22 @@ color: $sharp-line; } - .rt-expandable:empty { - cursor: default; - } + .rt-tbody { + .rt-td.rt-expandable { + text-align: center; - .rt-expander::after { - border-top-color: $sharp-line; - } + &:empty { + cursor: default; + } - .hide-rt-expander { - &.rt-expandable { - cursor: default; - } + &.hide-rt-expander { + cursor: default; - .rt-expander { - display: none; + .rt-expander, + .rt-expander-custom { + display: none; + } + } } } @@ -658,3 +644,9 @@ margin-bottom: 1em; width: 100%; } + +// Additional custom styles +.loading-spinner { + padding: 10px 0; + text-align: center; +} diff --git a/app/assets/stylesheets/common/core.scss b/app/assets/stylesheets/common/core.scss index 3694f75186..e5d864ff6b 100644 --- a/app/assets/stylesheets/common/core.scss +++ b/app/assets/stylesheets/common/core.scss @@ -332,3 +332,25 @@ option.uncollected { display: inline-block; width: 16px; } + +// Section-specific visibility radio buttons - vertical stacking +.section-visibility-options { + display: flex; + flex-direction: column; + gap: 0.5em; +} + +.section-visibility-option { + display: flex; + align-items: center; + gap: 0.25em; + + input[type='radio'] { + margin: 0; + } + + label { + margin: 0; + cursor: pointer; + } +} diff --git a/app/assets/stylesheets/grader.scss b/app/assets/stylesheets/grader.scss index 08c853ac44..2a61dd3caa 100644 --- a/app/assets/stylesheets/grader.scss +++ b/app/assets/stylesheets/grader.scss @@ -133,9 +133,10 @@ font-weight: bold; padding-bottom: 5px; - .arrow-down, - .arrow-up { + .chevron-expandable { + float: left; margin-right: 5px; + margin-top: 2px; } } diff --git a/app/contracts/test_results_contract.rb b/app/contracts/test_results_contract.rb new file mode 100644 index 0000000000..982df0727f --- /dev/null +++ b/app/contracts/test_results_contract.rb @@ -0,0 +1,66 @@ +class TestResultsContract < Dry::Validation::Contract + params do + required(:error).maybe(:string) + required(:status).filled(:string) + + required(:test_groups).array(:hash) do + required(:time).maybe(:integer) + optional(:timeout).maybe(:integer) + optional(:stderr).maybe(:string) + optional(:malformed).maybe(:string) + + required(:tests).array(:hash) do + required(:name).filled(:string) + required(:time).maybe(:integer) + required(:output).value(:string) + required(:status).filled(:string) + required(:marks_total).filled(:integer) + required(:marks_earned).filled(:integer) + end + + required(:extra_info).maybe(:hash) do + required(:name).filled(:string) + optional(:criterion).maybe(:string) + required(:test_group_id).filled(:integer) + required(:display_output).value(:string) + end + + optional(:annotations).array(:hash) do + required(:content).filled(:string) + required(:filename).filled(:string) + optional(:type).filled(:string) + optional(:line_start).maybe(:integer) + optional(:line_end).maybe(:integer) + optional(:column_start).maybe(:integer) + optional(:column_end).maybe(:integer) + optional(:x1).maybe(:integer) + optional(:x2).maybe(:integer) + optional(:y1).maybe(:integer) + optional(:y2).maybe(:integer) + optional(:start_node).maybe(:string) + optional(:start_offset).maybe(:integer) + optional(:end_node).maybe(:string) + optional(:end_offset).maybe(:integer) + end + + optional(:feedback).array(:hash) do + required(:filename).filled(:string) + required(:mime_type).filled(:string) + required(:content).filled(:string) + optional(:compression).filled(:string) + end + + optional(:tags).array(:hash) do + required(:name).filled(:string) + optional(:description).maybe(:string) + end + + optional(:overall_comment).maybe(:string) + optional(:extra_marks).array(:hash) do + required(:unit).filled(:string) + required(:mark).filled(:integer) + required(:description).filled(:string) + end + end + end +end diff --git a/app/controllers/admin/courses_controller.rb b/app/controllers/admin/courses_controller.rb index 8572b74428..901a81a902 100644 --- a/app/controllers/admin/courses_controller.rb +++ b/app/controllers/admin/courses_controller.rb @@ -1,6 +1,7 @@ module Admin class CoursesController < ApplicationController include AutomatedTestsHelper::AutotestApi + DEFAULT_FIELDS = [:id, :name, :is_hidden, :display_name].freeze before_action { authorize! } @@ -36,7 +37,7 @@ def update def test_autotest_connection settings = current_course.autotest_setting - return head :unprocessable_entity unless settings&.url + return head :unprocessable_content unless settings&.url begin get_schema(current_course.autotest_setting) flash_now(:success, I18n.t('automated_tests.manage_connection.test_success', url: settings.url)) @@ -50,7 +51,7 @@ def test_autotest_connection def reset_autotest_connection settings = current_course.autotest_setting - return head :unprocessable_entity unless settings&.url + return head :unprocessable_content unless settings&.url @current_job = AutotestResetUrlJob.perform_later(current_course, settings.url, request.protocol + request.host_with_port, diff --git a/app/controllers/api/assignments_controller.rb b/app/controllers/api/assignments_controller.rb index d4b25704e3..b464e1fb0a 100644 --- a/app/controllers/api/assignments_controller.rb +++ b/app/controllers/api/assignments_controller.rb @@ -11,7 +11,7 @@ class AssignmentsController < MainApiController :student_form_groups, :remark_due_date, :remark_message, :assign_graders_to_criteria, :enable_test, :enable_student_tests, :allow_remarks, :display_grader_names_to_students, :group_name_autogenerated, - :repository_folder, :is_hidden, :vcs_submit, :token_period, + :repository_folder, :is_hidden, :visible_on, :visible_until, :vcs_submit, :token_period, :non_regenerating_tokens, :unlimited_tokens, :token_start_date, :token_end_date, :has_peer_review, :starter_file_type, :default_starter_file_group_id].freeze @@ -57,7 +57,7 @@ def create if has_missing_params?([:short_identifier, :due_date, :description]) # incomplete/invalid HTTP params render 'shared/http_status', locals: { code: '422', message: - HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_entity + HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_content return end @@ -205,7 +205,7 @@ def update_test_specs begin content = JSON.parse params[:specs] rescue JSON::ParserError => e - render 'shared/http_status', locals: { code: '422', message: e.message }, status: :unprocessable_entity + render 'shared/http_status', locals: { code: '422', message: e.message }, status: :unprocessable_content return end end @@ -213,7 +213,7 @@ def update_test_specs render 'shared/http_status', locals: { code: '422', message: HttpStatusHelper::ERROR_CODE['message']['422'] }, - status: :unprocessable_entity + status: :unprocessable_content else AutotestSpecsJob.perform_now(request.protocol + request.host_with_port, assignment, content) end diff --git a/app/controllers/api/courses_controller.rb b/app/controllers/api/courses_controller.rb index 0bf8635d4f..3abd94843d 100644 --- a/app/controllers/api/courses_controller.rb +++ b/app/controllers/api/courses_controller.rb @@ -2,6 +2,7 @@ module Api # API controller for Courses class CoursesController < MainApiController include AutomatedTestsHelper::AutotestApi + DEFAULT_FIELDS = [:id, :name, :is_hidden, :display_name].freeze def index @@ -27,7 +28,7 @@ def show def create Course.create!(params.permit(:name, :is_hidden, :display_name)) rescue ActiveRecord::SubclassNotFound, ActiveRecord::RecordInvalid => e - render 'shared/http_status', locals: { code: '422', message: e.to_s }, status: :unprocessable_entity + render 'shared/http_status', locals: { code: '422', message: e.to_s }, status: :unprocessable_content rescue StandardError render 'shared/http_status', locals: { code: '500', message: HttpStatusHelper::ERROR_CODE['message']['500'] }, status: :internal_server_error @@ -39,7 +40,7 @@ def create def update current_course.update!(params.permit(:name, :is_hidden, :display_name)) rescue ActiveRecord::SubclassNotFound, ActiveRecord::RecordInvalid => e - render 'shared/http_status', locals: { code: '422', message: e.to_s }, status: :unprocessable_entity + render 'shared/http_status', locals: { code: '422', message: e.to_s }, status: :unprocessable_content rescue StandardError render 'shared/http_status', locals: { code: '500', message: HttpStatusHelper::ERROR_CODE['message']['500'] }, status: :internal_server_error @@ -51,7 +52,7 @@ def update def update_autotest_url AutotestResetUrlJob.perform_now(current_course, params[:url], request.protocol + request.host_with_port) rescue ActiveRecord::SubclassNotFound, ActiveRecord::RecordInvalid => e - render 'shared/http_status', locals: { code: '422', message: e.to_s }, status: :unprocessable_entity + render 'shared/http_status', locals: { code: '422', message: e.to_s }, status: :unprocessable_content rescue StandardError render 'shared/http_status', locals: { code: '500', message: HttpStatusHelper::ERROR_CODE['message']['500'] }, status: :internal_server_error @@ -83,7 +84,7 @@ def test_autotest_connection end else render 'shared/http_status', locals: { code: '422', message: - I18n.t('automated_tests.no_autotest_settings') }, status: :unprocessable_entity + I18n.t('automated_tests.no_autotest_settings') }, status: :unprocessable_content end end @@ -100,7 +101,7 @@ def reset_autotest_connection end else render 'shared/http_status', locals: { code: '422', message: - I18n.t('automated_tests.no_autotest_settings') }, status: :unprocessable_entity + I18n.t('automated_tests.no_autotest_settings') }, status: :unprocessable_content end end diff --git a/app/controllers/api/feedback_files_controller.rb b/app/controllers/api/feedback_files_controller.rb index c7489ba816..8a8422c96c 100644 --- a/app/controllers/api/feedback_files_controller.rb +++ b/app/controllers/api/feedback_files_controller.rb @@ -56,7 +56,7 @@ def create if has_missing_params?([:filename, :mime_type, :file_content]) # incomplete/invalid HTTP params render 'shared/http_status', locals: { code: '422', message: - HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_entity + HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_content return end @@ -85,7 +85,7 @@ def create message: I18n.t('oversize_feedback_file', file_size: ActiveSupport::NumberHelper.number_to_human_size(size_diff), max_file_size: submission.course.max_file_size / 1_000_000) }, - status: :payload_too_large + status: :content_too_large return end if submission.feedback_files.create(filename: params[:filename], diff --git a/app/controllers/api/grade_entry_forms_controller.rb b/app/controllers/api/grade_entry_forms_controller.rb index efa47903e2..f4250ce488 100644 --- a/app/controllers/api/grade_entry_forms_controller.rb +++ b/app/controllers/api/grade_entry_forms_controller.rb @@ -1,6 +1,7 @@ module Api class GradeEntryFormsController < MainApiController - DEFAULT_FIELDS = [:id, :short_identifier, :description, :due_date, :is_hidden, :show_total].freeze + DEFAULT_FIELDS = [:id, :short_identifier, :description, :due_date, :is_hidden, :visible_on, :visible_until, + :show_total].freeze # Sends the contents of the specified grade entry form # Requires: id @@ -40,7 +41,7 @@ def create if has_missing_params?([:short_identifier]) # incomplete/invalid HTTP params render 'shared/http_status', locals: { code: '422', message: - HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_entity + HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_content return end @@ -60,7 +61,7 @@ def create new_form = GradeEntryForm.new(create_params) unless new_form.save render 'shared/http_status', locals: { code: '422', message: - new_form.errors.full_messages.first }, status: :unprocessable_entity + new_form.errors.full_messages.first }, status: :unprocessable_content raise ActiveRecord::Rollback end @@ -69,7 +70,7 @@ def create grade_item = new_form.grade_entry_items.build(**column_params, position: i + 1) unless grade_item.save render 'shared/http_status', locals: { code: '422', message: - grade_item.errors.full_messages.first }, status: :unprocessable_entity + grade_item.errors.full_messages.first }, status: :unprocessable_content raise ActiveRecord::Rollback end end @@ -137,7 +138,7 @@ def update_grades if has_missing_params?([:user_name, :grade_entry_items]) # incomplete/invalid HTTP params render 'shared/http_status', locals: { code: '422', message: - HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_entity + HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_content return end @@ -151,7 +152,7 @@ def update_grades if grade_entry_student.nil? # There is no student with that user_name render 'shared/http_status', locals: { code: '422', message: - 'There is no student with that user_name' }, status: :unprocessable_entity + 'There is no student with that user_name' }, status: :unprocessable_content return end @@ -162,7 +163,7 @@ def update_grades if grade_entry_item.nil? # There is no such grade entry item render 'shared/http_status', locals: { code: '422', message: - "There is no grade entry item named #{item}" }, status: :unprocessable_entity + "There is no grade entry item named #{item}" }, status: :unprocessable_content raise ActiveRecord::Rollback end grade = grade_entry_student.grades.find_or_create_by(grade_entry_item_id: grade_entry_item.id) diff --git a/app/controllers/api/groups_controller.rb b/app/controllers/api/groups_controller.rb index b284d0c4bc..b571f04938 100644 --- a/app/controllers/api/groups_controller.rb +++ b/app/controllers/api/groups_controller.rb @@ -20,7 +20,7 @@ def create end rescue StandardError => e render 'shared/http_status', locals: { code: '422', message: - e.message }, status: :unprocessable_entity + e.message }, status: :unprocessable_content end end @@ -68,7 +68,7 @@ def add_members if self.grouping.nil? # The group doesn't have a grouping associated with that assignment render 'shared/http_status', locals: { code: '422', message: - 'The group is not involved with that assignment' }, status: :unprocessable_entity + 'The group is not involved with that assignment' }, status: :unprocessable_content return end @@ -281,7 +281,7 @@ def update_marking_state if has_missing_params?([:marking_state]) # incomplete/invalid HTTP params render 'shared/http_status', locals: { code: '422', message: - HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_entity + HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_content return end result = self.grouping&.current_submission_used&.get_latest_result @@ -336,7 +336,7 @@ def extension else # cannot delete a non existent extension; render failure render 'shared/http_status', locals: { code: '422', message: - HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_entity + HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_content end when 'POST' if grouping.extension.nil? @@ -349,7 +349,7 @@ def extension else # cannot create extension as it already exists; render failure render 'shared/http_status', locals: { code: '422', message: - HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_entity + HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_content end when 'PATCH' if grouping.extension.present? @@ -362,11 +362,11 @@ def extension else # cannot update extension as it does not exists; render failure render 'shared/http_status', locals: { code: '422', message: - HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_entity + HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_content end end rescue ActiveRecord::RecordInvalid => e - render 'shared/http_status', locals: { code: '422', message: e.to_s }, status: :unprocessable_entity + render 'shared/http_status', locals: { code: '422', message: e.to_s }, status: :unprocessable_content end def collect_submission @@ -375,7 +375,7 @@ def collect_submission released = @grouping.current_submission_used.results.exists?(released_to_students: true) if released render 'shared/http_status', locals: { code: '422', message: - HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_entity + HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_content return end end @@ -401,10 +401,52 @@ def collect_submission HttpStatusHelper::ERROR_CODE['message']['201'] }, status: :created end + def add_test_run + m_logger = MarkusLogger.instance + # Validate test results against contract schema + validation = TestResultsContract.new.call(params[:test_results].as_json) + + if validation.failure? + return render json: { errors: validation.errors.to_hash }, status: :unprocessable_content + end + + # Verify grouping exists and is authorized (grouping helper method handles this) + if grouping.nil? + return render json: { errors: 'Group not found for this assignment' }, status: :not_found + end + + # Verify submission exists before attempting to create test run + submission = grouping.current_submission_used + if submission.nil? + return render json: { errors: 'No submission exists for this grouping' }, status: :unprocessable_content + end + + begin + ActiveRecord::Base.transaction do + test_run = TestRun.create!( + status: :in_progress, + role: current_role, + grouping: grouping, + submission: submission + ) + + test_run.update_results!(JSON.parse(params[:test_results].to_json)) + render json: { status: 'success', test_run_id: test_run.id }, status: :created + end + rescue ActiveRecord::RecordInvalid => e + render json: { errors: e.record.errors.full_messages }, status: :unprocessable_content + rescue StandardError => e + m_logger.log("Test results processing failed: #{e.message}\n#{e.backtrace.join("\n")}") + render json: { errors: 'Failed to process test results' }, status: :internal_server_error + end + end + private def assignment - @assignment ||= Assignment.find_by(id: params[:assignment_id]) + return @assignment if defined?(@assignment) + + @assignment = Assignment.find_by(id: params[:assignment_id]) end def grouping diff --git a/app/controllers/api/main_api_controller.rb b/app/controllers/api/main_api_controller.rb index b2d2d3c426..076628d17f 100644 --- a/app/controllers/api/main_api_controller.rb +++ b/app/controllers/api/main_api_controller.rb @@ -17,7 +17,7 @@ class MainApiController < ActionController::Base # rubocop:disable Rails/Applica before_action { authorize! } AUTHTYPE = 'MarkUsAuth'.freeze - AUTH_TOKEN_REGEX = /#{AUTHTYPE} ([^\s,]+)/.freeze + AUTH_TOKEN_REGEX = /#{AUTHTYPE} ([^\s,]+)/ def page_not_found(message = HttpStatusHelper::ERROR_CODE['message']['404']) render 'shared/http_status', @@ -70,12 +70,12 @@ def get_collection(collection) filter_params = params[:filter] ? params[:filter].permit(self.class::DEFAULT_FIELDS) : {} if params[:filter].present? && filter_params.empty? render 'shared/http_status', locals: { code: '422', message: - 'Invalid or malformed parameter values' }, status: :unprocessable_entity + 'Invalid or malformed parameter values' }, status: :unprocessable_content false elsif filter_params.empty? - collection.order('id') + collection.order(:id) else - collection.order('id').where(**filter_params) + collection.order(:id).where(**filter_params) end end diff --git a/app/controllers/api/roles_controller.rb b/app/controllers/api/roles_controller.rb index 4463b907be..db725c9da6 100644 --- a/app/controllers/api/roles_controller.rb +++ b/app/controllers/api/roles_controller.rb @@ -129,7 +129,7 @@ def create_role HttpStatusHelper::ERROR_CODE['message']['201'] }, status: :created end rescue ActiveRecord::SubclassNotFound, ActiveRecord::RecordInvalid, ActiveRecord::RecordNotFound => e - render 'shared/http_status', locals: { code: '422', message: e.to_s }, status: :unprocessable_entity + render 'shared/http_status', locals: { code: '422', message: e.to_s }, status: :unprocessable_content rescue StandardError render 'shared/http_status', locals: { code: '500', message: HttpStatusHelper::ERROR_CODE['message']['500'] }, status: :internal_server_error @@ -151,7 +151,7 @@ def update_role(role) render 'shared/http_status', locals: { code: '200', message: HttpStatusHelper::ERROR_CODE['message']['200'] }, status: :ok rescue ActiveRecord::SubclassNotFound, ActiveRecord::RecordInvalid, ActiveRecord::RecordNotFound => e - render 'shared/http_status', locals: { code: '422', message: e.to_s }, status: :unprocessable_entity + render 'shared/http_status', locals: { code: '422', message: e.to_s }, status: :unprocessable_content rescue StandardError render 'shared/http_status', locals: { code: '500', message: HttpStatusHelper::ERROR_CODE['message']['500'] }, status: :internal_server_error @@ -161,7 +161,7 @@ def find_role_by_username if has_missing_params?([:user_name]) # incomplete/invalid HTTP params render 'shared/http_status', locals: { code: '422', message: - HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_entity + HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_content return end @@ -181,7 +181,7 @@ def filtered_roles if filter_params.empty? render 'shared/http_status', locals: { code: '422', message: 'Invalid or malformed parameter values' }, - status: :unprocessable_entity + status: :unprocessable_content return false else return collection.where(**filter_params) diff --git a/app/controllers/api/sections_controller.rb b/app/controllers/api/sections_controller.rb index ef9e2391e2..a4e5a57eb1 100644 --- a/app/controllers/api/sections_controller.rb +++ b/app/controllers/api/sections_controller.rb @@ -35,7 +35,7 @@ def create locals: { code: '201', message: HttpStatusHelper::ERROR_CODE['message']['201'] }, status: :created else render 'shared/http_status', locals: { code: '422', message: - HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_entity + HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_content end end diff --git a/app/controllers/api/starter_file_groups_controller.rb b/app/controllers/api/starter_file_groups_controller.rb index 80675f58f8..11772f7e3d 100644 --- a/app/controllers/api/starter_file_groups_controller.rb +++ b/app/controllers/api/starter_file_groups_controller.rb @@ -61,7 +61,7 @@ def create_file if has_missing_params?([:filename, :file_content]) # incomplete/invalid HTTP params render 'shared/http_status', locals: { code: '422', message: - HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_entity + HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_content return end @@ -73,7 +73,7 @@ def create_file file_path = FileHelper.checked_join(starter_file_group.path, params[:filename]) if file_path.nil? render 'shared/http_status', locals: { code: '422', message: - HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_entity + HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_content else File.write(file_path, content, mode: 'wb') update_entries_and_warn(starter_file_group) @@ -91,14 +91,14 @@ def create_folder if has_missing_params?([:folder_path]) # incomplete/invalid HTTP params render 'shared/http_status', locals: { code: '422', message: - HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_entity + HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_content return end folder_path = FileHelper.checked_join(starter_file_group.path, params[:folder_path]) if folder_path.nil? render 'shared/http_status', locals: { code: '422', message: - HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_entity + HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_content else FileUtils.mkdir_p(folder_path) update_entries_and_warn(starter_file_group) @@ -116,13 +116,13 @@ def remove_file if has_missing_params?([:filename]) # incomplete/invalid HTTP params render 'shared/http_status', locals: { code: '422', message: - HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_entity + HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_content return end file_path = FileHelper.checked_join(starter_file_group.path, params[:filename]) if file_path.nil? render 'shared/http_status', locals: { code: '422', message: - HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_entity + HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_content else File.delete(file_path) update_entries_and_warn(starter_file_group) @@ -140,14 +140,14 @@ def remove_folder if has_missing_params?([:folder_path]) # incomplete/invalid HTTP params render 'shared/http_status', locals: { code: '422', message: - HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_entity + HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_content return end folder_path = FileHelper.checked_join(starter_file_group.path, params[:folder_path]) if folder_path.nil? render 'shared/http_status', locals: { code: '422', message: - HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_entity + HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_content else FileUtils.rm_rf(folder_path) update_entries_and_warn(starter_file_group) diff --git a/app/controllers/api/submission_files_controller.rb b/app/controllers/api/submission_files_controller.rb index 60d94ace0b..feda235d17 100644 --- a/app/controllers/api/submission_files_controller.rb +++ b/app/controllers/api/submission_files_controller.rb @@ -3,6 +3,7 @@ module Api # Uses Rails' RESTful routes (check 'rake routes' for the configured routes) class SubmissionFilesController < MainApiController include SubmissionsHelper + # Returns the requested submission file, or a zip containing all submission # files, including all annotations if requested # Requires: assignment_id, group_id @@ -40,7 +41,7 @@ def index file = revision.files_at_path(File.join(assignment.repository_folder, path))[file_name] if file.nil? render 'shared/http_status', locals: { code: '422', message: - HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_entity + HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_content return end file_contents = repo.download_as_string(file) @@ -88,7 +89,7 @@ def create_folders if has_missing_params?([:folder_path]) # incomplete/invalid HTTP params render 'shared/http_status', locals: { code: '422', message: - HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_entity + HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_content return end success, messages = grouping.access_repo do |repo| @@ -115,7 +116,7 @@ def remove_file if has_missing_params?([:filename]) # incomplete/invalid HTTP params render 'shared/http_status', locals: { code: '422', message: - HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_entity + HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_content return end @@ -143,7 +144,7 @@ def remove_folder if has_missing_params?([:folder_path]) # incomplete/invalid HTTP params render 'shared/http_status', locals: { code: '422', message: - HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_entity + HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_content return end success, messages = grouping.access_repo do |repo| diff --git a/app/controllers/api/tags_controller.rb b/app/controllers/api/tags_controller.rb index 932cf24d84..f76e7c97dc 100644 --- a/app/controllers/api/tags_controller.rb +++ b/app/controllers/api/tags_controller.rb @@ -36,7 +36,7 @@ def create grouping.tags << new_tag end rescue StandardError => e - render 'shared/http_status', locals: { code: '422', message: e.to_s }, status: :unprocessable_entity + render 'shared/http_status', locals: { code: '422', message: e.to_s }, status: :unprocessable_content else render 'shared/http_status', locals: { code: '201', message: HttpStatusHelper::ERROR_CODE['message']['201'] }, status: :created @@ -50,7 +50,7 @@ def update begin tag.update!(**self.tag_params) rescue StandardError => e - render 'shared/http_status', locals: { code: '422', message: e.to_s }, status: :unprocessable_entity + render 'shared/http_status', locals: { code: '422', message: e.to_s }, status: :unprocessable_content else render 'shared/http_status', locals: { code: '200', message: HttpStatusHelper::ERROR_CODE['message']['200'] }, status: :ok diff --git a/app/controllers/api/users_controller.rb b/app/controllers/api/users_controller.rb index c0bc586cb6..8cb6de3f7f 100644 --- a/app/controllers/api/users_controller.rb +++ b/app/controllers/api/users_controller.rb @@ -23,7 +23,7 @@ def create if has_missing_params?([:user_name, :type, :first_name, :last_name]) # incomplete/invalid HTTP params render 'shared/http_status', locals: { code: '422', message: - HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_entity + HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_content return end @@ -47,11 +47,11 @@ def create AdminUser.create!(params.permit(*DEFAULT_FIELDS)) else render 'shared/http_status', locals: { code: '422', message: 'Unknown user type' }, - status: :unprocessable_entity + status: :unprocessable_content return end rescue ActiveRecord::SubclassNotFound, ActiveRecord::RecordInvalid => e - render 'shared/http_status', locals: { code: '422', message: e.to_s }, status: :unprocessable_entity + render 'shared/http_status', locals: { code: '422', message: e.to_s }, status: :unprocessable_content else render 'shared/http_status', locals: { code: '201', message: HttpStatusHelper::ERROR_CODE['message']['201'] }, status: :created @@ -85,7 +85,7 @@ def update end user.update!(user_params) rescue ActiveRecord::SubclassNotFound, ActiveRecord::RecordInvalid => e - render 'shared/http_status', locals: { code: '422', message: e.to_s }, status: :unprocessable_entity + render 'shared/http_status', locals: { code: '422', message: e.to_s }, status: :unprocessable_content rescue StandardError render 'shared/http_status', locals: { code: '500', message: HttpStatusHelper::ERROR_CODE['message']['500'] }, status: :internal_server_error @@ -102,7 +102,7 @@ def update_by_username # incomplete/invalid HTTP params render 'shared/http_status', locals: { code: '422', message: HttpStatusHelper::ERROR_CODE['message']['422'] }, - status: :unprocessable_entity + status: :unprocessable_content return end @@ -113,7 +113,7 @@ def update_by_username end user.update!(user_params) rescue ActiveRecord::SubclassNotFound, ActiveRecord::RecordInvalid => e - render 'shared/http_status', locals: { code: '422', message: e.to_s }, status: :unprocessable_entity + render 'shared/http_status', locals: { code: '422', message: e.to_s }, status: :unprocessable_content rescue StandardError render 'shared/http_status', locals: { code: '500', message: HttpStatusHelper::ERROR_CODE['message']['500'] }, status: :internal_server_error diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index d1b3575409..98b82b522f 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -56,8 +56,8 @@ def page_not_found protected - def use_time_zone(&block) - Time.use_zone(current_user.time_zone, &block) + def use_time_zone(&) + Time.use_zone(current_user.time_zone, &) end # Set locale according to URL parameter. If unknown parameter is diff --git a/app/controllers/assignments_controller.rb b/app/controllers/assignments_controller.rb index b4cb38e683..51f5ffd456 100644 --- a/app/controllers/assignments_controller.rb +++ b/app/controllers/assignments_controller.rb @@ -2,6 +2,7 @@ class AssignmentsController < ApplicationController include RepositoryHelper include RoutingHelper include AutomatedTestsHelper + responders :flash before_action { authorize! } @@ -286,6 +287,22 @@ def download_test_results type: 'text/csv', filename: filename end + format.zip do + data = @assignment.summary_test_result_json + zip_name = "#{@assignment.short_identifier}_test_results.zip" + + zip_path = File.join('tmp', zip_name) + json_filename = "#{@assignment.short_identifier}_test_results.json" + + FileUtils.rm_f(zip_path) + + Zip::File.open(zip_path, create: true) do |zip_file| + zip_file.get_output_stream(json_filename) do |f| + f.write(data) + end + end + send_file zip_path, filename: zip_name, type: 'application/zip', disposition: 'attachment' + end end end @@ -534,7 +551,7 @@ def set_boolean_graders_options unless assignment.update(attributes) flash_now(:error, assignment.errors.full_messages.join(' ')) - head :unprocessable_entity + head :unprocessable_content return end head :ok @@ -831,7 +848,30 @@ def process_assignment_form(assignment) periods = period_attrs.to_h.values.map { |h| h[:id].presence } assignment.submission_rule.periods.where.not(id: periods).find_each(&:destroy) end - assignment.assign_attributes(assignment_params) + + # Handle "scheduled" visibility option + # When is_hidden="scheduled", convert to is_hidden=false with datetime values + params_copy = assignment_params.deep_dup + if params_copy[:is_hidden] == Assessment::SCHEDULED_VISIBILITY + params_copy[:is_hidden] = false + elsif params_copy[:is_hidden].present? + # Clear datetime fields when switching to "Hidden" or "Visible" + params_copy[:visible_on] = nil + params_copy[:visible_until] = nil + end + + # Handle section-specific "scheduled" visibility + params_copy[:assessment_section_properties_attributes]&.each_value do |section_props| + if section_props[:is_hidden] == Assessment::SCHEDULED_VISIBILITY + section_props[:is_hidden] = false + elsif section_props[:is_hidden].present? + # Clear datetime fields when switching to "Hidden", "Visible", or "Default" + section_props[:visible_on] = nil + section_props[:visible_until] = nil + end + end + + assignment.assign_attributes(params_copy) SubmissionRule.where(assignment: assignment).where.not(id: assignment.submission_rule.id).find_each(&:destroy) process_timed_duration(assignment) if assignment.is_timed assignment.repository_folder = short_identifier @@ -906,6 +946,8 @@ def assignment_params :message, :due_date, :is_hidden, + :visible_on, + :visible_until, :parent_assessment_id, assignment_properties_attributes: [ :id, @@ -941,7 +983,9 @@ def assignment_params :section_id, :due_date, :start_time, - :is_hidden + :is_hidden, + :visible_on, + :visible_until ], assignment_files_attributes: [ :_destroy, @@ -952,6 +996,7 @@ def assignment_params :_destroy, :id, :type, + :penalty_type, { periods_attributes: [ :id, :deduction, @@ -980,6 +1025,7 @@ def submission_rule_params :_destroy, :id, :type, + :penalty_type, { periods_attributes: [ :id, :deduction, diff --git a/app/controllers/automated_tests_controller.rb b/app/controllers/automated_tests_controller.rb index cf5642e704..8b7b61532d 100644 --- a/app/controllers/automated_tests_controller.rb +++ b/app/controllers/automated_tests_controller.rb @@ -219,17 +219,17 @@ def upload_specs test_specs = JSON.parse file_content rescue JSON::ParserError flash_now(:error, I18n.t('automated_tests.invalid_specs_file')) - head :unprocessable_entity + head :unprocessable_content rescue StandardError => e flash_now(:error, e.message) - head :unprocessable_entity + head :unprocessable_content else @current_job = AutotestSpecsJob.perform_later(request.protocol + request.host_with_port, assignment, test_specs) session[:job_id] = @current_job.job_id render 'shared/_poll_job' end else - head :unprocessable_entity + head :unprocessable_content end end diff --git a/app/controllers/canvas_controller.rb b/app/controllers/canvas_controller.rb index 96d437afac..d2b32851d8 100644 --- a/app/controllers/canvas_controller.rb +++ b/app/controllers/canvas_controller.rb @@ -39,7 +39,8 @@ def get_config custom_fields: { user_id: '$Canvas.user.id', course_id: '$Canvas.course.id', - course_name: '$Canvas.course.name' + course_name: '$Canvas.course.name', + student_number: '$Canvas.user.sisIntegrationId' } } diff --git a/app/controllers/courses_controller.rb b/app/controllers/courses_controller.rb index ba6a4dcafd..5e24656da5 100644 --- a/app/controllers/courses_controller.rb +++ b/app/controllers/courses_controller.rb @@ -79,7 +79,7 @@ def switch_role render partial: 'role_switch_handler', formats: [:js], handlers: [:erb], locals: { error: I18n.t('main.cannot_role_switch_to_self') }, - status: :unprocessable_entity + status: :unprocessable_content return end @@ -88,7 +88,7 @@ def switch_role render partial: 'role_switch_handler', formats: [:js], handlers: [:erb], locals: { error: I18n.t('main.cannot_role_switch') }, - status: :unprocessable_entity + status: :unprocessable_content return end diff --git a/app/controllers/criteria_controller.rb b/app/controllers/criteria_controller.rb index 1b911c6bb0..5a6ece2dfc 100644 --- a/app/controllers/criteria_controller.rb +++ b/app/controllers/criteria_controller.rb @@ -42,7 +42,7 @@ def create @criterion.errors.full_messages.each do |message| flash_message(:error, message) end - head :unprocessable_entity + head :unprocessable_content end end @@ -107,7 +107,7 @@ def update @criterion.errors.full_messages.each do |message| flash_message(:error, message) end - head :unprocessable_entity + head :unprocessable_content end end diff --git a/app/controllers/exam_templates_controller.rb b/app/controllers/exam_templates_controller.rb index 56303e611c..3a90083db4 100644 --- a/app/controllers/exam_templates_controller.rb +++ b/app/controllers/exam_templates_controller.rb @@ -102,7 +102,7 @@ def download_generate exam_template = record path = FileHelper.checked_join(exam_template.tmp_path, params[:file_name]) if path.nil? - head :unprocessable_entity + head :unprocessable_content else send_file(path, filename: params[:file_name], @@ -264,7 +264,7 @@ def download_error_file @assignment = record.assignment path = FileHelper.checked_join(exam_template.base_path, 'error', params[:file_name]) if path.nil? - head :unprocessable_entity + head :unprocessable_content else send_file(path, filename: params[:file_name], diff --git a/app/controllers/grade_entry_forms_controller.rb b/app/controllers/grade_entry_forms_controller.rb index fd1e206b6f..a5f4ab4ae5 100644 --- a/app/controllers/grade_entry_forms_controller.rb +++ b/app/controllers/grade_entry_forms_controller.rb @@ -3,6 +3,7 @@ class GradeEntryFormsController < ApplicationController include GradeEntryFormsHelper include RoutingHelper + before_action { authorize! } layout 'assignment_content' diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index b737bed253..12cb41fd2a 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -117,7 +117,9 @@ def index respond_to do |format| format.html format.json do - render json: @assignment.all_grouping_data + render json: @assignment.all_grouping_data.merge( + clone_assignments: @clone_assignments.as_json(only: [:id, :short_identifier]) + ) end end end @@ -181,11 +183,13 @@ def get_names .where(groupings: { assessment_id: params[:assignment_id] })) .pluck_to_hash(:id, 'users.id_number', 'users.user_name', 'users.first_name', 'users.last_name', 'roles.hidden') + names = names.map do |h| + inactive = h['roles.hidden'] ? I18n.t('student.inactive') : '' { id: h[:id], id_number: h['users.id_number'], user_name: h['users.user_name'], - value: "#{h['users.first_name']} #{h['users.last_name']}#{h['roles.hidden'] ? ' (inactive)' : ''}" } + value: "#{h['users.first_name']} #{h['users.last_name']}#{inactive}" } end render json: names end @@ -199,12 +203,15 @@ def assign_student_and_next if params[:s_id].present? student = current_course.students.find(params[:s_id]) end + replace_pattern = /#{Regexp.escape(I18n.t('student.inactive'))}\s*$/ + student_name = params[:names].sub(replace_pattern, '').strip + # if the user has typed in the whole name without select, or if they typed a name different from the select s_id - if student.nil? || "#{student.first_name} #{student.last_name}" != params[:names] + if student.nil? || "#{student.first_name} #{student.last_name}" != student_name student = current_course.students.joins(:user).where( 'lower(CONCAT(first_name, \' \', last_name)) like ? OR lower(CONCAT(last_name, \' \', first_name)) like ?', - ApplicationRecord.sanitize_sql_like(params[:names].downcase), - ApplicationRecord.sanitize_sql_like(params[:names].downcase) + ApplicationRecord.sanitize_sql_like(student_name.downcase), + ApplicationRecord.sanitize_sql_like(student_name.downcase) ).first end if student.nil? @@ -346,7 +353,7 @@ def use_another_assignment_groups target_assignment = Assignment.find(params[:assignment_id]) source_assignment = Assignment.find(params[:clone_assignment_id]) - return head :unprocessable_entity if target_assignment.course != source_assignment.course + return head :unprocessable_content if target_assignment.course != source_assignment.course if source_assignment.nil? flash_message(:warning, t('groups.clone_warning.could_not_find_source')) @@ -371,7 +378,7 @@ def accept_invitation current_role.join(@grouping) rescue ActiveRecord::RecordInvalid, RuntimeError => e flash_message(:error, e.message) - status = :unprocessable_entity + status = :unprocessable_content else m_logger = MarkusLogger.instance m_logger.log("Student '#{current_role.user_name}' joined group " \ @@ -389,7 +396,7 @@ def decline_invitation @grouping.decline_invitation(current_role) rescue RuntimeError => e flash_message(:error, e.message) - status = :unprocessable_entity + status = :unprocessable_content else m_logger = MarkusLogger.instance m_logger.log("Student '#{current_role.user_name}' declined invitation for group '#{@grouping.group.group_name}'.") diff --git a/app/controllers/lti_deployments_controller.rb b/app/controllers/lti_deployments_controller.rb index 56ed3f2f6d..94f9ed7c8d 100644 --- a/app/controllers/lti_deployments_controller.rb +++ b/app/controllers/lti_deployments_controller.rb @@ -12,7 +12,7 @@ class LtiDeploymentsController < ApplicationController def launch if params[:client_id].blank? || params[:login_hint].blank? || params[:target_link_uri].blank? || params[:lti_message_hint].blank? - head :unprocessable_entity + head :unprocessable_content return end nonce = rand(10 ** 30).to_s.rjust(30, '0') @@ -91,7 +91,12 @@ def redirect_login deployment_id: lti_params[LtiDeployment::LTI_CLAIMS[:deployment_id]], lms_course_name: lti_params[LtiDeployment::LTI_CLAIMS[:context]]['title'], lms_course_label: lti_params[LtiDeployment::LTI_CLAIMS[:context]]['label'], - lms_course_id: lti_params[LtiDeployment::LTI_CLAIMS[:custom]]['course_id'] } + lms_course_id: lti_params[LtiDeployment::LTI_CLAIMS[:custom]]['course_id'], + user_roles: lti_params[LtiDeployment::LTI_CLAIMS[:roles]] } + if lti_params.key?(LtiDeployment::LTI_CLAIMS[:rlid]) + rlid = lti_params[LtiDeployment::LTI_CLAIMS[:rlid]]['id'] + lti_data[:resource_link_id] = rlid + end if lti_params.key?(LtiDeployment::LTI_CLAIMS[:names_role]) name_and_roles_endpoint = lti_params[LtiDeployment::LTI_CLAIMS[:names_role]]['context_memberships_url'] lti_data[:names_role_service] = name_and_roles_endpoint @@ -122,7 +127,10 @@ def redirect_login lti_deployment = LtiDeployment.find_or_initialize_by(lti_client: lti_client, external_deployment_id: lti_data[:deployment_id], lms_course_id: lti_data[:lms_course_id]) - lti_deployment.update!(lms_course_name: lti_data[:lms_course_name]) + lti_deployment.update!( + lms_course_name: lti_data[:lms_course_name], + resource_link_id: lti_data[:resource_link_id] + ) session[:lti_course_label] = lti_data[:lms_course_label] if lti_data.key?(:names_role_service) names_service = LtiService.find_or_initialize_by(lti_deployment: lti_deployment, service_type: 'namesrole') @@ -135,9 +143,18 @@ def redirect_login LtiUser.find_or_create_by(user: @real_user, lti_client: lti_client, lti_user_id: lti_data[:lti_user_id]) if lti_deployment.course.nil? - # Redirect to course picker page - redirect_to choose_course_lti_deployment_path(lti_deployment) + # Check if the user has any of the privileged roles + has_privileged_role = lti_data[:user_roles].any? do |role_uri| + LtiDeployment::LTI_PRIVILEGED_ROLES.include?(role_uri) + end + has_ta_role = lti_data[:user_roles].include?(LtiDeployment::LTI_ROLES[:ta]) + if has_privileged_role && !has_ta_role + redirect_to choose_course_lti_deployment_path(lti_deployment) + else + redirect_to course_not_set_up_lti_deployment_path(lti_deployment) + end else + # Course is linked, proceed to the course path redirect_to course_path(lti_deployment.course) end ensure @@ -150,6 +167,10 @@ def public_jwk render json: { keys: [jwk.export] } end + def course_not_set_up + render 'course_not_set_up', status: :not_found + end + def choose_course @lti_deployment = record if request.post? @@ -176,7 +197,7 @@ def check_host known_lti_hosts << URI(root_url).host if known_lti_hosts.exclude?(URI(request.referer).host) render 'shared/http_status', locals: { code: '422', message: I18n.t('lti.config_error') }, - status: :unprocessable_entity, layout: false + status: :unprocessable_content, layout: false nil end end diff --git a/app/controllers/main_controller.rb b/app/controllers/main_controller.rb index cd2ce16f7d..2a5f879294 100644 --- a/app/controllers/main_controller.rb +++ b/app/controllers/main_controller.rb @@ -118,7 +118,7 @@ def about # Render 404 error (page not found) if no other route matches. # See config/routes.rb - def page_not_found # rubocop:disable Lint/UselessMethodDefinition + def page_not_found super end diff --git a/app/controllers/results_controller.rb b/app/controllers/results_controller.rb index aef1bfa5e4..963fafc93b 100644 --- a/app/controllers/results_controller.rb +++ b/app/controllers/results_controller.rb @@ -284,6 +284,7 @@ def edit def run_tests submission = record.submission + assignment = Grouping.find(submission.grouping_id).assignment # If no test groups can be run by instructors, flash appropriate message and return early test_group_categories = assignment.test_groups.pluck(:autotest_settings).pluck('category') @@ -292,6 +293,7 @@ def run_tests flash_now(:info, I18n.t('automated_tests.no_instructor_runnable_tests')) return end + flash_message(:notice, I18n.t('automated_tests.autotest_run_job.status.in_progress')) AutotestRunJob.perform_later(request.protocol + request.host_with_port, current_role.id, diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb index 1530b53ef1..3f3d9e6cf5 100644 --- a/app/controllers/submissions_controller.rb +++ b/app/controllers/submissions_controller.rb @@ -3,6 +3,7 @@ class SubmissionsController < ApplicationController include SubmissionsHelper include RepositoryHelper + before_action { authorize! } authorize :from_codeviewer, through: :from_codeviewer_param @@ -461,7 +462,7 @@ def update_files messages << commit_msg head :ok else - head :unprocessable_entity + head :unprocessable_content end end flash_repository_messages messages, @grouping.course @@ -507,7 +508,7 @@ def download_file max_content_size = params[:max_content_size].blank? ? -1 : params[:max_content_size].to_i if max_content_size != -1 && file_contents.size > max_content_size - head :payload_too_large + head :content_too_large return end diff --git a/app/helpers/automated_tests_helper.rb b/app/helpers/automated_tests_helper.rb index fd61f88c0f..5aa6a1696c 100644 --- a/app/helpers/automated_tests_helper.rb +++ b/app/helpers/automated_tests_helper.rb @@ -121,6 +121,7 @@ def get_markus_address(host_with_port) # Sends RESTful api requests to the autotester module AutotestApi include AutomatedTestsHelper + AUTOTEST_USERNAME = "markus_#{Rails.application.config.relative_url_root}".freeze class LimitExceededException < StandardError; end diff --git a/app/helpers/lti_helper.rb b/app/helpers/lti_helper.rb index 324b4add2b..751fccb51e 100644 --- a/app/helpers/lti_helper.rb +++ b/app/helpers/lti_helper.rb @@ -10,6 +10,15 @@ def roster_sync(lti_deployment, role_types, can_create_users: false, can_create_ auth_data = lti_deployment.lti_client.get_oauth_token([LtiDeployment::LTI_SCOPES[:names_role]]) names_service = lti_deployment.lti_services.find_by!(service_type: 'namesrole') membership_uri = URI(names_service.url) + if lti_deployment.resource_link_id.present? + query = begin + URI.decode_www_form(String(membership_uri.query)) + rescue StandardError + [] + end + query << ['rlid', lti_deployment.resource_link_id] + membership_uri.query = URI.encode_www_form(query) + end member_info, follow = get_member_data(lti_deployment, membership_uri, auth_data) while follow != false additional_data, follow = get_member_data(lti_deployment, follow, auth_data) @@ -18,11 +27,18 @@ def roster_sync(lti_deployment, role_types, can_create_users: false, can_create_ user_data = member_info.filter_map do |user| unless user['status'] == 'Inactive' || user['roles'].include?(LtiDeployment::LTI_ROLES['test_user']) || role_types.none? { |role| user['roles'].include?(role) } + student_number = user.dig('message', 0, LtiDeployment::LTI_CLAIMS[:custom], 'student_number') + id_number_value = if student_number.blank? || student_number == '$Canvas.user.sisIntegrationId' + nil + else + student_number + end { user_name: user['lis_person_sourcedid'].nil? ? user['name'].delete(' ') : user['lis_person_sourcedid'], first_name: user['given_name'], last_name: user['family_name'], display_name: user['name'], email: user['email'], + id_number: id_number_value, lti_user_id: user['user_id'], roles: user['roles'] } end @@ -101,6 +117,7 @@ def get_assignment_marks(lti_deployment, assignment) released_results = assignment.released_marks .joins(grouping: [{ accepted_student_memberships: { role: { user: :lti_users } } }]) .where('lti_users.lti_client': lti_deployment.lti_client) + .where(roles: { hidden: false }) .pluck('lti_users.lti_user_id', 'results.id') result_ids = released_results.pluck(1) grades = Result.get_total_marks(result_ids) diff --git a/app/helpers/submissions_helper.rb b/app/helpers/submissions_helper.rb index 125fc5a49a..15434b5746 100644 --- a/app/helpers/submissions_helper.rb +++ b/app/helpers/submissions_helper.rb @@ -57,7 +57,7 @@ def upload_file(grouping, only_required_files: false) if has_missing_params?([:filename, :mime_type, :file_content]) # incomplete/invalid HTTP params render 'shared/http_status', locals: { code: '422', message: - HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_entity + HttpStatusHelper::ERROR_CODE['message']['422'] }, status: :unprocessable_content return end @@ -66,7 +66,7 @@ def upload_file(grouping, only_required_files: false) if FileHelper.checked_join(path.to_s, filename).nil? message = I18n.t('errors.invalid_path') - render 'shared/http_status', locals: { code: '422', message: message }, status: :unprocessable_entity + render 'shared/http_status', locals: { code: '422', message: message }, status: :unprocessable_content return end @@ -75,7 +75,7 @@ def upload_file(grouping, only_required_files: false) if only_required_files && required_files.exclude?(filename) message = t('assignments.upload_file_requirement', file_name: params[:filename]) + "\n#{Assignment.human_attribute_name(:assignment_files)}: #{required_files.join(', ')}" - render 'shared/http_status', locals: { code: '422', message: message }, status: :unprocessable_entity + render 'shared/http_status', locals: { code: '422', message: message }, status: :unprocessable_content return end diff --git a/app/javascript/Components/DropDown/MultiSelectDropDown.jsx b/app/javascript/Components/DropDown/MultiSelectDropDown.jsx index 08285b28fd..3226270312 100644 --- a/app/javascript/Components/DropDown/MultiSelectDropDown.jsx +++ b/app/javascript/Components/DropDown/MultiSelectDropDown.jsx @@ -64,9 +64,9 @@ export class MultiSelectDropdown extends React.Component { let expanded = this.state.expanded; let arrow; if (expanded !== false) { - arrow = ; + arrow = ; } else { - arrow = ; + arrow = ; } return ( diff --git a/app/javascript/Components/DropDown/SingleSelectDropDown.jsx b/app/javascript/Components/DropDown/SingleSelectDropDown.jsx index 5af54a0bad..54bbfe4421 100644 --- a/app/javascript/Components/DropDown/SingleSelectDropDown.jsx +++ b/app/javascript/Components/DropDown/SingleSelectDropDown.jsx @@ -57,9 +57,11 @@ export class SingleSelectDropDown extends React.Component { renderArrow = () => { if (this.state.expanded !== false) { - return ; + return ; } else { - return ; + return ( + + ); } }; diff --git a/app/javascript/Components/Helpers/table_helpers.jsx b/app/javascript/Components/Helpers/table_helpers.jsx index 08797daf91..9936c34749 100644 --- a/app/javascript/Components/Helpers/table_helpers.jsx +++ b/app/javascript/Components/Helpers/table_helpers.jsx @@ -1,10 +1,32 @@ import React from "react"; +import {Grid} from "react-loader-spinner"; /** * @file * Provides generic helper functions and components for react-table tables. */ +export function customLoadingProp({loading}) { + if (loading) { + return ( +
+ +
+ ); + } + + return null; +} + export function defaultSort(a, b) { // Sort values, putting undefined/nulls below all other values. // Based on react-table v6 defaultSortMethod (https://github.com/tannerlinsley/react-table/tree/v6/), @@ -230,7 +252,10 @@ export function getMarkingStates(data) { return markingStates; } -export function customNoDataComponent({children}) { +export function customNoDataComponent({children, loading}) { + if (loading) { + return null; + } return

{children}

; } diff --git a/app/javascript/Components/Modals/assignment_group_use_modal.jsx b/app/javascript/Components/Modals/assignment_group_use_modal.jsx new file mode 100644 index 0000000000..4854204d21 --- /dev/null +++ b/app/javascript/Components/Modals/assignment_group_use_modal.jsx @@ -0,0 +1,90 @@ +import React from "react"; +import Modal from "react-modal"; + +export default class AssignmentGroupUseModal extends React.Component { + constructor(props) { + super(props); + this.state = { + assignmentId: "", + isLoading: false, + }; + } + + componentDidMount() { + Modal.setAppElement("body"); + } + + componentDidUpdate(prevProps) { + if (this.props.isOpen && !prevProps.isOpen) { + if (this.props.cloneAssignments.length > 0) { + this.setState({ + assignmentId: this.props.cloneAssignments[0].id, + }); + } else { + this.setState({ + assignmentId: "", + }); + } + } + } + + handleChange = event => { + this.setState({assignmentId: event.target.value}); + }; + + handleSubmit = event => { + event.preventDefault(); + if (window.confirm(I18n.t("groups.delete_groups_linked"))) { + this.setState({isLoading: true}); + this.props.onSubmit(this.state.assignmentId); + } + }; + + render() { + return ( + +

{I18n.t("groups.reuse_groups")}

+
+ {I18n.t("groups.assignment_to_use")} + +
+ + +
+
+
+ ); + } +} diff --git a/app/javascript/Components/Modals/create_group_modal.jsx b/app/javascript/Components/Modals/create_group_modal.jsx new file mode 100644 index 0000000000..3ecc57ecde --- /dev/null +++ b/app/javascript/Components/Modals/create_group_modal.jsx @@ -0,0 +1,59 @@ +import React from "react"; +import Modal from "react-modal"; + +export default class CreateGroupModal extends React.Component { + constructor(props) { + super(props); + this.state = { + groupName: "", + }; + } + + componentDidMount() { + Modal.setAppElement("body"); + } + + handleChange = event => { + this.setState({groupName: event.target.value}); + }; + + handleSubmit = event => { + event.preventDefault(); + if (!this.state.groupName) { + return; + } + this.props.onSubmit(this.state.groupName); + this.setState({groupName: ""}); + }; + + render() { + return ( + +

{I18n.t("helpers.submit.create", {model: I18n.t("activerecord.models.group.one")})}

+
+ + this.handleChange(event)} + autoFocus + /> +
+ + +
+
+
+ ); + } +} diff --git a/app/javascript/Components/Modals/download_test_results_modal.jsx b/app/javascript/Components/Modals/download_test_results_modal.jsx index 35f12f18a0..9b780b8146 100644 --- a/app/javascript/Components/Modals/download_test_results_modal.jsx +++ b/app/javascript/Components/Modals/download_test_results_modal.jsx @@ -53,6 +53,23 @@ class DownloadTestResultsModal extends React.Component { {I18n.t("download_csv")} + +