diff --git a/.envrc b/.envrc new file mode 100644 index 000000000..3550a30f2 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f359a147..5eb29e31d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,40 @@ on: branches: [ master, bs4 ] workflow_dispatch: jobs: + sorbet: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_HOST_AUTH_METHOD: trust + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + RAILS_ENV: development + DATABASE_URL: postgres://postgres@localhost:5432/tmc-sorbet + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '4.0' + bundler-cache: true + - name: Type check + run: bundle exec srb tc + - name: Verify gem RBIs + run: bin/tapioca gem --verify + - name: Load database schema + run: bin/rake db:create db:schema:load + - name: Verify DSL RBIs + run: bin/tapioca dsl --verify + spec_v8: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/rubocop.yml b/.github/workflows/rubocop.yml index 26fb03484..319f313a6 100644 --- a/.github/workflows/rubocop.yml +++ b/.github/workflows/rubocop.yml @@ -12,10 +12,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up Ruby 3.0 + - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: 3.0 + ruby-version: '4.0' - name: Cache gems uses: actions/cache@v4 with: @@ -29,3 +29,5 @@ jobs: bundle install --jobs 4 --retry 3 - name: Run RuboCop run: bundle exec rubocop + - name: Run Brakeman + run: bundle exec brakeman --no-pager --quiet --ensure-ignore-notes diff --git a/.gitignore b/.gitignore index e21f73f37..1f1a6f24a 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ # Ignore default active_storage storage/ + +# Nix / direnv +/.direnv diff --git a/.rubocop.yml b/.rubocop.yml index 9a57309cc..6a441525f 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,16 +1,131 @@ +inherit_from: .rubocop_todo.yml + +plugins: + - rubocop-capybara + - rubocop-factory_bot + - rubocop-performance + - rubocop-rails + - rubocop-rspec + - rubocop-sorbet + - rubocop-thread_safety + inherit_gem: rubocop-rails_config: - config/rails.yml AllCops: SuggestExtensions: false - TargetRubyVersion: 2.5 + NewCops: enable + TargetRubyVersion: 4.0 + TargetRailsVersion: 8.1 Exclude: - 'ext/**/*' - 'db/**/*' - '**/vendor/**/*' +# rails_config sets DisabledByDefault; enabling a department opts in its default +# cops without turning off the rails_config ones. +Lint: + Enabled: true + +Security: + Enabled: true + +Performance: + Enabled: true + +Rails: + Enabled: true + +# Swagger docs are public and must skip ApplicationController's filters. +Rails/ApplicationController: + Exclude: + - 'app/controllers/api/v8/apidocs_controller.rb' + +# There is no ApplicationMailer to inherit from. +Rails/ApplicationMailer: + Enabled: false + +# SessionsHelper and AuthorizeCollectionHelper are controller mixins that hold request state. +Rails/HelperInstanceVariable: + Exclude: + - 'app/helpers/sessions_helper.rb' + - 'app/helpers/authorize_collection_helper.rb' + +# API v8 returns the not-found message, which find words differently from find_by!(id:). +Rails/FindById: + Enabled: false + +# Each association needs a data-retention decision, not a lint fix. +Rails/HasManyOrHasOneDependent: + Enabled: false + +# The UI is not localised. +Rails/I18nLocaleTexts: + Enabled: false + +# Each inverse_of needs testing against the association's callers. +Rails/InverseOf: + Enabled: false + +# Console-run maintenance methods and the init script installer print progress for the operator. +Rails/Output: + Exclude: + - 'app/models/kafka_batch_update_points.rb' + - 'lib/ruby_init_script.rb' + +# Brakeman's XSS checks cover html_safe and raw. +Rails/OutputSafety: + Enabled: false + +# Rake tasks here don't use the app, and loading it would require a database config. +Rails/RakeEnvironment: + Enabled: false + +# RemoteSandbox.all is a plain array of sandboxes, not a relation. +Rails/RedundantActiveRecordAllMethod: + AllowedReceivers: + - RemoteSandbox + +# Dropping the validation changes the error messages API clients receive. +Rails/RedundantPresenceValidationOnBelongsTo: + Enabled: false + +# Per-origin email templates live in config/email_templates, outside the view paths. +Rails/RenderInline: + Exclude: + - 'app/mailers/user_mailer.rb' + +# update_column/update_all skip callbacks on purpose for counters and bulk updates. +Rails/SkipsModelValidations: + Enabled: false + +# params.expect rejects shapes require/permit accept; each endpoint needs its own review. +Rails/StrongParametersExpect: + Enabled: false + +# Time.zone.now formats differently from Time.now; each call site needs its own review. +Rails/TimeZone: + Enabled: false + +# Needs unique indexes added in migrations, not a lint fix. +Rails/UniqueValidationWithoutIndex: + Enabled: false + +# Decision tables of literal or constant results (Solution#visible_to?) read better unmerged. +Lint/DuplicateBranch: + IgnoreLiteralBranches: true + IgnoreConstantBranches: true + +# Would rewrite the Swagger DSL's `key :'$ref'` to `key :$ref`. +Lint/SymbolConversion: + Enabled: false + # Style +# Ruby 3.1+ shorthand (`{ x: }`) would rewrite ~600 hashes; not worth the churn. +Style/HashSyntax: + EnforcedShorthandSyntax: either + Style/StringLiterals: Enabled: true EnforcedStyle: single_quotes @@ -47,5 +162,192 @@ Metrics/ParameterLists: Metrics/PerceivedComplexity: Enabled: false -Performance/RegexpMatch: - Enabled: false \ No newline at end of file +Style/MapToHash: + Enabled: true + +# Spec-only plugins: bug-finding cops on, style cops off. +RSpec: + Include: + - 'spec/**/*.rb' + +Capybara: + Include: + - 'spec/**/*.rb' + +FactoryBot: + Include: + - 'spec/**/*.rb' + +RSpec/Be: + Enabled: true + +RSpec/BeNil: + Enabled: true + +RSpec/ChangeByZero: + Enabled: true + +RSpec/DuplicatedMetadata: + Enabled: true + +RSpec/EmptyExampleGroup: + Enabled: true + +RSpec/ExpectActual: + Enabled: true + +RSpec/ExpectOutput: + Enabled: true + +RSpec/Focus: + Enabled: true + +RSpec/HooksBeforeExamples: + Enabled: true + +RSpec/IdenticalEqualityAssertion: + Enabled: true + +RSpec/LeakyLocalVariable: + Enabled: true + +RSpec/MissingExampleGroupArgument: + Enabled: true + +RSpec/MissingExpectationTargetMethod: + Enabled: true + +RSpec/NoExpectationExample: + Enabled: true + # Shared helpers that hold the expectations. + AllowedPatterns: + - ^expect_ + - ^assert_ + - ^should_be_ + - ^(all|users)_own_subs$ + - ^no_other_ + - ^two_(subs|courses)_by_ + - ^course_from_different_template$ + +RSpec/NotToNot: + Enabled: true + +RSpec/OverwritingSetup: + Enabled: true + +RSpec/PendingWithoutReason: + Enabled: true + +RSpec/RepeatedDescription: + Enabled: true + +RSpec/RepeatedExample: + Enabled: true + +RSpec/RepeatedExampleGroupBody: + Enabled: true + +RSpec/RepeatedExampleGroupDescription: + Enabled: true + +RSpec/RepeatedIncludeExample: + Enabled: true + +RSpec/RepeatedSubjectCall: + Enabled: true + +RSpec/ReturnFromStub: + Enabled: true + +RSpec/SkipBlockInsideExample: + Enabled: true + +RSpec/UnspecifiedException: + Enabled: true + +RSpec/VoidExpect: + Enabled: true + +Capybara/CurrentPathExpectation: + Enabled: true + +Capybara/RSpec/NegationMatcherAfterVisit: + Enabled: true + +Capybara/VisibilityMatcher: + Enabled: true + +FactoryBot/AttributeDefinedStatically: + Enabled: true + +FactoryBot/FactoryAssociationWithStrategy: + Enabled: true + +FactoryBot/FactoryClassName: + Enabled: true + +FactoryBot/RedundantFactoryOption: + Enabled: true + +# Specs and standalone scripts run single-threaded. +ThreadSafety: + Enabled: true + Include: + - 'app/**/*.rb' + - 'lib/**/*.rb' + - 'config/**/*.rb' + +Sorbet/ValidSigil: + Enabled: true + +Sorbet/EnforceSigilOrder: + Enabled: true + +Sorbet/StrictSigil: + Enabled: true + Include: + - 'app/**/*.rb' + - 'lib/**/*.rb' + - 'config/**/*.rb' + Exclude: + # DSL files read with instance_eval; Sorbet ignores them. + - 'config/extra_fields/*' + +Sorbet/EnforceSignatures: + Enabled: true + Include: + - 'app/**/*.rb' + - 'lib/**/*.rb' + - 'config/**/*.rb' + Exclude: + - 'config/extra_fields/*' + # Sigs live in sorbet/rbi/shims/lib_natsort.rbi. + - 'lib/natsort.rb' + +Sorbet/SignatureBuildOrder: + Enabled: true + +Sorbet/ConstantsFromStrings: + Enabled: true + +# Cops that catch code a Rails upgrade breaks or deprecates. +Rails/EnumSyntax: + Enabled: true + +Rails/EnumHash: + Enabled: true + +Rails/DeprecatedActiveModelErrorsMethods: + Enabled: true + +Rails/HttpPositionalArguments: + Enabled: true + +Rails/TransactionExitStatement: + Enabled: true + +Rails/ActionOrder: + Enabled: true + +Rails/ActiveRecordCallbacksOrder: + Enabled: true diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml new file mode 100644 index 000000000..03fd4ad91 --- /dev/null +++ b/.rubocop_todo.yml @@ -0,0 +1,30 @@ +# This configuration was generated by +# `rubocop --auto-gen-config --exclude-limit 1000 --no-auto-gen-timestamp` +# using RuboCop version 1.92.2. +# The point is for the user to remove these configuration records +# one by one as the offenses are removed from the code base. +# Note that changes in the inspected code, or installation of new +# versions of RuboCop, may require this file to be generated again. + +# Offense count: 10 +ThreadSafety/ClassInstanceVariable: + Exclude: + - 'app/models/extra_field.rb' + - 'app/models/remote_sandbox.rb' + - 'app/models/site_setting.rb' + - 'lib/app_secrets.rb' + - 'lib/background_task_registry.rb' + - 'lib/tailoring.rb' + +# Offense count: 3 +# Configuration parameters: AllowCallWithBlock. +ThreadSafety/DirChdir: + Exclude: + - 'app/background_tasks/rust_langs_downloader_task.rb' + - 'app/models/course.rb' + - 'lib/doc_gen/shell_session.rb' + +# Offense count: 1 +ThreadSafety/NewThread: + Exclude: + - 'app/background_tasks/uncomputed_unlock_computor_task_backup.rb' diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 000000000..7919852fe --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +4.0.9 diff --git a/Contributing.md b/Contributing.md index 51b4490ff..75b431d1d 100644 --- a/Contributing.md +++ b/Contributing.md @@ -4,6 +4,14 @@ Check http://testmycode.github.io for contribution guidelines! +## Git hooks + +The Nix dev shell (`nix develop`) installs [lefthook](https://lefthook.dev) hooks. +On commit, `rubocop --autocorrect` runs on the staged Ruby files and the fixes are re-staged. +It never blocks a commit. Skip it with `LEFTHOOK=0 git commit ...`. + +Before opening a PR, run `bin/branch-ready-checks` (rubocop, brakeman, `srb tc`, and the tapioca verifications; needs a database). + ## Tips running tests It may take some time to run whole test suite, thus while developing one @@ -13,6 +21,9 @@ file. ##### To run all code style validations `bundle exec rubocop` +##### To run the security scan +`bundle exec brakeman`. Record a reviewed false positive, with a note saying why it is safe, through `bundle exec brakeman -I`. + ##### To run all tests `rvmsudo rake spec` diff --git a/Dockerfile b/Dockerfile index 8d88a267d..f35f69d58 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM eu.gcr.io/moocfi-public/tmc-server-base:latest LABEL name=tmc-server RUN apt-get update \ - && apt-get -y install curl gnupg nodejs chromium \ + && apt-get -y install curl gnupg nodejs chromium libvips42t64 \ && rm -rf /var/lib/apt/lists/* ADD Gemfile /app/Gemfile diff --git a/DockerfileBase b/DockerfileBase index fabdc92f5..e2c7d28e1 100644 --- a/DockerfileBase +++ b/DockerfileBase @@ -1,24 +1,13 @@ -FROM ruby:3.3 -LABEL name tmc-server-base +FROM ruby:4.0-trixie +LABEL name=tmc-server-base -ENV PHANTOMJS_VERSION 2.1.1 -ENV MAVEN_VERSION 3.3.9 -ENV MAVEN_HOME /usr/share/maven -ENV M3_HOME /usr/share/maven +ENV MAVEN_HOME=/usr/share/maven +ENV M3_HOME=/usr/share/maven -RUN wget --no-check-certificate -O /tmp/phantomjs.tar.bz2 https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-$PHANTOMJS_VERSION-linux-x86_64.tar.bz2 && \ - tar -xjf /tmp/phantomjs.tar.bz2 -C /tmp && \ - rm -f /tmp/phantomjs.tar.bz2 && \ - mkdir -p /srv/var && \ - mv /tmp/phantomjs* /srv/var/phantomjs && \ - ln -s /srv/var/phantomjs/bin/phantomjs /usr/bin/phantomjs && \ - apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs vim lsof wget libfreetype6 libfontconfig bzip2 libfreetype6 libfontconfig bzip2 zip openjdk-17-jdk postgresql-client check python3 maven && \ +RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs vim lsof wget bzip2 zip openjdk-21-jdk postgresql-client check python3 maven && \ rm -rf /var/lib/apt/lists/* && \ mkdir /app &&\ git config --global user.name "TmcTest" && \ git config --global user.email "tmc@example.com" -# Phantomjs workaround -ENV OPENSSL_CONF /dev/null - WORKDIR /app diff --git a/Gemfile b/Gemfile index 483ac3252..4e447df03 100644 --- a/Gemfile +++ b/Gemfile @@ -4,77 +4,89 @@ source 'https://rubygems.org' # An alternative when rubygems.org is down # source 'http://production.cf.rubygems.org/' -gem 'rails', '~> 7.1', '>= 7.1.3.4' +gem 'rails', '~> 8.1.4' -gem 'mini_racer', '~> 0.19.2' -gem 'activerecord-import', '~> 1.7' -gem 'activerecord-session_store', '~> 2.1' -gem 'argon2', '~> 2.3' -gem 'bootstrap', '~> 4.6' # Held back to 4.6 -gem 'cancancan', '~> 3.6', '>= 3.6.1' -gem 'doorkeeper', '~> 5.7', '>= 5.7.1' -gem 'doorkeeper-openid_connect', '~> 1.8', '>= 1.8.9' -gem 'faraday', '~> 2.14' +gem 'mini_racer', '~> 0.22' +gem 'activerecord-import', '~> 3.0' +gem 'activerecord-session_store', '~> 2.3' +gem 'argon2', '~> 2.3', '>= 2.3.3' +gem 'bootstrap', '~> 4.6' # 5.x drops the jQuery plugins and markup the views rely on +gem 'cancancan', '~> 3.7' +gem 'doorkeeper', '~> 5.9', '>= 5.9.9' +gem 'doorkeeper-openid_connect', '~> 2.1' +gem 'faraday', '~> 2.15' gem 'file_validators', '~> 3.0' # Used to validate organization logo -gem 'font-awesome-rails', '~> 4.7', '>= 4.7.0.8' +gem 'font-awesome-rails', '~> 4.7', '>= 4.7.0.9' gem 'gravtastic', '~> 3.2', '>= 3.2.6' # Used to display user avatars -gem 'image_processing', '~> 1.12', '>= 1.12.2' # Used by active_storage to make logo 100x100 on the fly -gem 'logstasher', '~> 2.1', '>= 2.1.5' -gem 'natcmp', '~> 1.4', '>= 1.4.3' -gem 'newrelic_rpm', '~> 9.11' +gem 'image_processing', '~> 2.2' # Active Storage variants of organization logos +gem 'logstasher', '~> 3.0' +gem 'natcmp', '~> 1.4', '>= 1.4.4' +gem 'newrelic_rpm', '~> 10.9' gem 'pdfkit', '~> 0.8.7.3', require: false -gem 'pg', '~> 1.5', '>= 1.5.6' -gem 'pghero', '~> 3.5' -gem 'rack-attack', '~> 6.7', require: false -gem 'rack-cors', '~> 2.0', '>= 2.0.2' +gem 'pg', '~> 1.7' +gem 'pghero', '~> 4.1' +gem 'rack-attack', '~> 6.8', require: false +gem 'rack-cors', '~> 3.0' gem 'rack-timeout', '~> 0.7.0' -gem 'rack-mini-profiler', '~> 3.3', '>= 3.3.1' +gem 'rack-mini-profiler', '~> 5.0' gem 'rake', '~> 13.2', '>= 13.2.1' -gem 'responders', '~> 3.1', '>= 3.1.1' +gem 'responders', '~> 3.2' gem 'rest-client', '~> 2.1', require: false +gem 'ruby-vips', '~> 2.3', require: false # Active Storage loads it on first use, so booting needs no libvips gem 'swagger-blocks', '~> 3.0' -gem 'sassc-rails', '~> 2.1', '>= 2.1.2' +gem 'dartsass-sprockets', '~> 3.2' gem 'xml-simple', '~> 1.1', '>= 1.1.9', require: false -gem 'cgi', '~> 0.3.6' +gem 'cgi', '~> 0.5' +gem 'sorbet-runtime', '~> 0.6.13640' -gem 'hiredis', '~> 0.6.3' # Redis for caching -gem 'readthis', '~> 2.2' # Redis for caching -gem 'redis', '~> 4.5' # Redis for caching (TODO: Remove and migrate to rails cache) +# Bundled rather than default gems from Ruby 3.4/4.0 on, so Bundler must list them +gem 'benchmark', '~> 0.5.0' +gem 'csv', '~> 3.3', '>= 3.3.7' +gem 'ostruct', '~> 0.6.3' + +gem 'redis', '~> 6.0' # Rails.cache and Rack::MiniProfiler in production group :assets do - gem 'jquery-rails', '~> 4.6' - gem 'sprockets-rails', '~> 3.5', '>= 3.5.1', require: 'sprockets/railtie' - gem 'uglifier', '~> 4.2' + gem 'jquery-rails', '~> 4.6', '>= 4.6.1' + gem 'sprockets-rails', '~> 3.5', '>= 3.5.2', require: 'sprockets/railtie' + gem 'uglifier', '~> 4.2', '>= 4.2.1' end group :development, :test do gem 'capybara', '~> 3.40' - gem 'factory_bot_rails', '~> 6.4', '>= 6.4.3' - gem 'puma', '~> 6.4', '>= 6.4.2' - gem 'thin', '~> 1.8', '>= 1.8.2' # A transitive dependency, this forces the latest version + gem 'factory_bot_rails', '~> 6.5' + gem 'puma', '~> 8.0' # gem 'passenger', '~> 5.0', require: "phusion_passenger/rack_handler" - gem 'poltergeist', '~> 1.18', '>= 1.18.1' + gem 'cuprite', '~> 0.20' gem 'rspec', '~> 3.13' - gem 'rspec-activemodel-mocks', '~> 1.2' + gem 'rspec-activemodel-mocks', '~> 1.3' gem 'rspec-core', '~> 3.13' - gem 'rspec-rails', '~> 6.1', '>= 6.1.3' + gem 'rspec-rails', '~> 8.1' gem 'rails-controller-testing', '~> 1.0', '>= 1.0.5' # gem 'selenium-webdriver', '~> 2.44.0' - gem 'irb', '~> 1.14' - gem 'brakeman', '~> 6.1', '>= 6.1.2', require: false + gem 'irb', '~> 1.18' + gem 'brakeman', '~> 8.1', require: false gem 'bundler-audit', '~> 0.9.1' - gem 'pry', '~> 0.14.2' - gem 'pry-byebug', '~> 3.10', '>= 3.10.1' + gem 'pry', '~> 0.16' + gem 'pry-byebug', '~> 3.12' gem 'pry-rails', '~> 0.3.11' - gem 'database_cleaner', '~> 2.0', '>= 2.0.2' - gem 'launchy', '~> 3.0', '>= 3.0.1' # for capybara's save_and_open_page - gem 'mimic', '~> 0.4.4' + gem 'database_cleaner', '~> 2.1' + gem 'launchy', '~> 3.1' # for capybara's save_and_open_page gem 'railroady', '~> 1.6' # for doc/diagrams - gem 'rubocop', '~> 1.65', require: false - gem 'rubocop-rails_config', '~> 1.16' # Rubocop locked - gem 'ruby-prof', '~> 1.7' # for performance tests - gem 'simplecov', '~> 0.22.0', require: false + gem 'rubocop', '~> 1.92', require: false + gem 'rubocop-capybara', '~> 3.1', require: false + gem 'rubocop-factory_bot', '~> 2.28', require: false + gem 'rubocop-performance', '~> 1.27', require: false + gem 'rubocop-rails', '~> 2.38', require: false + gem 'rubocop-rails_config', '~> 1.17' + gem 'rubocop-rspec', '~> 3.10', require: false + gem 'rubocop-sorbet', '~> 0.17', require: false + gem 'rubocop-thread_safety', '~> 0.8', require: false + gem 'simplecov', '~> 1.3', require: false + gem 'sorbet', '~> 0.6.13640', require: false + gem 'spoom', '~> 1.8', require: false + gem 'tapioca', '~> 0.20.0', require: false end group :development do @@ -82,5 +94,5 @@ group :development do end group :test do - gem 'json-schema', '~> 4.3' + gem 'json-schema', '~> 6.2' end diff --git a/Gemfile.lock b/Gemfile.lock index 46ed5c02e..265b7b576 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,111 +1,111 @@ GEM remote: https://rubygems.org/ specs: - actioncable (7.1.3.4) - actionpack (= 7.1.3.4) - activesupport (= 7.1.3.4) + action_text-trix (2.1.19) + railties + actioncable (8.1.4.1) + actionpack (= 8.1.4.1) + activesupport (= 8.1.4.1) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.1.3.4) - actionpack (= 7.1.3.4) - activejob (= 7.1.3.4) - activerecord (= 7.1.3.4) - activestorage (= 7.1.3.4) - activesupport (= 7.1.3.4) - mail (>= 2.7.1) - net-imap - net-pop - net-smtp - actionmailer (7.1.3.4) - actionpack (= 7.1.3.4) - actionview (= 7.1.3.4) - activejob (= 7.1.3.4) - activesupport (= 7.1.3.4) - mail (~> 2.5, >= 2.5.4) - net-imap - net-pop - net-smtp + actionmailbox (8.1.4.1) + actionpack (= 8.1.4.1) + activejob (= 8.1.4.1) + activerecord (= 8.1.4.1) + activestorage (= 8.1.4.1) + activesupport (= 8.1.4.1) + mail (>= 2.8.0) + actionmailer (8.1.4.1) + actionpack (= 8.1.4.1) + actionview (= 8.1.4.1) + activejob (= 8.1.4.1) + activesupport (= 8.1.4.1) + mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (7.1.3.4) - actionview (= 7.1.3.4) - activesupport (= 7.1.3.4) + actionpack (8.1.4.1) + actionview (= 8.1.4.1) + activesupport (= 8.1.4.1) nokogiri (>= 1.8.5) - racc rack (>= 2.2.4) rack-session (>= 1.0.1) rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - actiontext (7.1.3.4) - actionpack (= 7.1.3.4) - activerecord (= 7.1.3.4) - activestorage (= 7.1.3.4) - activesupport (= 7.1.3.4) + useragent (~> 0.16) + actiontext (8.1.4.1) + action_text-trix (~> 2.1.15) + actionpack (= 8.1.4.1) + activerecord (= 8.1.4.1) + activestorage (= 8.1.4.1) + activesupport (= 8.1.4.1) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.1.3.4) - activesupport (= 7.1.3.4) + actionview (8.1.4.1) + activesupport (= 8.1.4.1) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) - rails-html-sanitizer (~> 1.6) - activejob (7.1.3.4) - activesupport (= 7.1.3.4) + rails-html-sanitizer (~> 1.6, >= 1.7.2) + activejob (8.1.4.1) + activesupport (= 8.1.4.1) globalid (>= 0.3.6) - activemodel (7.1.3.4) - activesupport (= 7.1.3.4) - activerecord (7.1.3.4) - activemodel (= 7.1.3.4) - activesupport (= 7.1.3.4) + activemodel (8.1.4.1) + activesupport (= 8.1.4.1) + activerecord (8.1.4.1) + activemodel (= 8.1.4.1) + activesupport (= 8.1.4.1) timeout (>= 0.4.0) - activerecord-import (1.7.0) - activerecord (>= 4.2) - activerecord-session_store (2.1.0) - actionpack (>= 6.1) + activerecord-import (3.0.1) activerecord (>= 6.1) + activerecord-session_store (2.3.0) + actionpack (>= 7.1) + activerecord (>= 7.1) cgi (>= 0.3.6) - multi_json (~> 1.11, >= 1.11.2) rack (>= 2.0.8, < 4) - railties (>= 6.1) - activestorage (7.1.3.4) - actionpack (= 7.1.3.4) - activejob (= 7.1.3.4) - activerecord (= 7.1.3.4) - activesupport (= 7.1.3.4) + railties (>= 7.1) + activestorage (8.1.4.1) + actionpack (= 8.1.4.1) + activejob (= 8.1.4.1) + activerecord (= 8.1.4.1) + activesupport (= 8.1.4.1) marcel (~> 1.0) - activesupport (7.1.3.4) + activesupport (8.1.4.1) base64 bigdecimal - concurrent-ruby (~> 1.0, >= 1.0.2) + concurrent-ruby (~> 1.0, >= 1.3.1) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) + json + logger (>= 1.4.2) minitest (>= 5.1) - mutex_m - tzinfo (~> 2.0) - addressable (2.8.7) - public_suffix (>= 2.0.2, < 7.0) - argon2 (2.3.0) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + addressable (2.9.2) + public_suffix (>= 2.0.2, < 8.0) + argon2 (2.3.3) ffi (~> 1.15) ffi-compiler (~> 1.0) - ast (2.4.2) - autoprefixer-rails (10.4.16.0) + ast (2.4.3) + autoprefixer-rails (10.6.1.0) execjs (~> 2) - base64 (0.2.0) - bigdecimal (3.1.8) - bootstrap (4.6.2) + base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (4.1.3) + bootstrap (4.6.2.1) autoprefixer-rails (>= 9.1.0) popper_js (>= 1.16.1, < 2) - sassc-rails (>= 2.0.0) - brakeman (6.1.2) + brakeman (8.1.0) racc builder (3.3.0) - bundler-audit (0.9.1) - bundler (>= 1.2.0, < 3) + bundler-audit (0.9.3) + bundler (>= 1.2.0) thor (~> 1.0) - byebug (11.1.3) - cancancan (3.6.1) + byebug (13.0.0) + reline (>= 0.6.0) + cancancan (3.7.0) capybara (3.40.0) addressable matrix @@ -115,461 +115,559 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) - cgi (0.3.6) - childprocess (5.0.0) - cliver (0.3.2) + cgi (0.5.2) + childprocess (5.1.0) + logger (~> 1.5) coderay (1.1.3) - concurrent-ruby (1.3.3) - connection_pool (2.4.1) - crass (1.0.6) - daemons (1.4.1) - database_cleaner (2.0.2) + concurrent-ruby (1.3.8) + connection_pool (3.0.2) + crass (1.0.8) + csv (3.3.7) + cuprite (0.20) + capybara (~> 3.0) + ferrum (~> 0.20.1) + dartsass-sprockets (3.2.1) + railties (>= 4.0.0) + sassc-embedded (~> 1.80.1) + sprockets (> 3.0) + sprockets-rails + tilt + database_cleaner (2.1.0) database_cleaner-active_record (>= 2, < 3) - database_cleaner-active_record (2.2.0) + database_cleaner-active_record (2.2.2) activerecord (>= 5.a) - database_cleaner-core (~> 2.0.0) - database_cleaner-core (2.0.1) - date (3.3.4) - diff-lcs (1.5.1) - docile (1.4.1) - domain_name (0.6.20240107) - doorkeeper (5.7.1) + database_cleaner-core (~> 2.0) + database_cleaner-core (2.1.0) + date (3.6.0) + diff-lcs (1.6.2) + domain_name (0.7.20261031) + doorkeeper (5.9.9) railties (>= 5) - doorkeeper-openid_connect (1.8.9) - doorkeeper (>= 5.5, < 5.8) + doorkeeper-openid_connect (2.1.0) + doorkeeper (>= 5.5, < 7.0) jwt (>= 2.5) - drb (2.2.1) - erubi (1.13.0) - eventmachine (1.2.7) - execjs (2.9.1) - factory_bot (6.4.6) - activesupport (>= 5.0.0) - factory_bot_rails (6.4.3) - factory_bot (~> 6.4) - railties (>= 5.0.0) - faraday (2.14.0) - faraday-net_http (>= 2.0, < 3.5) + drb (2.2.3) + erb (6.0.7) + erubi (1.13.1) + execjs (2.10.2) + json (>= 2) + factory_bot (6.6.0) + activesupport (>= 6.1.0) + factory_bot_rails (6.5.1) + factory_bot (~> 6.5) + railties (>= 6.1.0) + faraday (2.15.1) + faraday-net_http (>= 2.0, < 3.6) json logger - faraday-net_http (3.4.2) + faraday-net_http (3.5.0) net-http (~> 0.5) - ffi (1.17.0-aarch64-linux-gnu) - ffi (1.17.0-aarch64-linux-musl) - ffi (1.17.0-arm-linux-gnu) - ffi (1.17.0-arm-linux-musl) - ffi (1.17.0-arm64-darwin) - ffi (1.17.0-x86-linux-gnu) - ffi (1.17.0-x86-linux-musl) - ffi (1.17.0-x86_64-darwin) - ffi (1.17.0-x86_64-linux-gnu) - ffi (1.17.0-x86_64-linux-musl) - ffi-compiler (1.3.2) + ferrum (0.20.1) + addressable (~> 2.5) + base64 (~> 0.2) + concurrent-ruby (~> 1.1) + websocket-driver (~> 0.7) + ffi (1.17.4-aarch64-linux-gnu) + ffi (1.17.4-aarch64-linux-musl) + ffi (1.17.4-arm64-darwin) + ffi (1.17.4-x86_64-darwin) + ffi (1.17.4-x86_64-linux-gnu) + ffi (1.17.4-x86_64-linux-musl) + ffi-compiler (1.4.3) ffi (>= 1.15.5) rake file_validators (3.0.0) activemodel (>= 3.2) mime-types (>= 1.0) - font-awesome-rails (4.7.0.8) - railties (>= 3.2, < 8.0) - globalid (1.2.1) + font-awesome-rails (4.7.0.9) + railties (>= 3.2, < 9.0) + globalid (1.4.0) activesupport (>= 6.1) + google-protobuf (4.36.2-aarch64-linux-gnu) + bigdecimal + rake (~> 13.3) + google-protobuf (4.36.2-aarch64-linux-musl) + bigdecimal + rake (~> 13.3) + google-protobuf (4.36.2-arm64-darwin) + bigdecimal + rake (~> 13.3) + google-protobuf (4.36.2-x86_64-darwin) + bigdecimal + rake (~> 13.3) + google-protobuf (4.36.2-x86_64-linux-gnu) + bigdecimal + rake (~> 13.3) + google-protobuf (4.36.2-x86_64-linux-musl) + bigdecimal + rake (~> 13.3) gravtastic (3.2.6) - hiredis (0.6.3) http-accept (1.7.0) - http-cookie (1.0.6) + http-cookie (1.1.6) domain_name (~> 0.5) - i18n (1.14.5) + i18n (1.15.2) concurrent-ruby (~> 1.0) - image_processing (1.13.0) - mini_magick (>= 4.9.5, < 5) - ruby-vips (>= 2.0.17, < 3) - io-console (0.7.2) - irb (1.14.0) + image_processing (2.2.0) + io-console (0.9.5) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) rdoc (>= 4.0.0) reline (>= 0.4.2) - jquery-rails (4.6.0) + jquery-rails (4.6.1) rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) - json (2.7.2) - json-schema (4.3.1) - addressable (>= 2.8) - jwt (2.8.2) + json (3.1.0) + json-schema (6.2.1) + addressable (~> 2.8) + bigdecimal (>= 3.1, < 5) + jwt (3.3.0) base64 - language_server-protocol (3.17.0.3) - launchy (3.0.1) + kramdown (2.5.2) + rexml (>= 3.4.4) + kramdown-parser-gfm (1.1.0) + kramdown (~> 2.0) + language_server-protocol (3.17.0.6) + launchy (3.1.1) addressable (~> 2.8) childprocess (~> 5.0) + logger (~> 1.6) letter_opener (1.10.0) launchy (>= 2.2, < 4) - libv8-node (24.12.0.1) libv8-node (24.12.0.1-aarch64-linux) libv8-node (24.12.0.1-aarch64-linux-musl) libv8-node (24.12.0.1-arm64-darwin) libv8-node (24.12.0.1-x86_64-darwin) libv8-node (24.12.0.1-x86_64-linux) libv8-node (24.12.0.1-x86_64-linux-musl) - logger (1.6.0) - logstasher (2.1.5) - activesupport (>= 5.2) + lint_roller (1.1.0) + logger (1.7.0) + logstasher (3.0.0) + activesupport (>= 7.0) request_store - loofah (2.22.0) + loofah (2.25.3) crass (~> 1.0.2) - nokogiri (>= 1.12.0) - mail (2.8.1) + nokogiri (>= 1.19.5) + mail (2.9.1) + logger mini_mime (>= 0.1.1) net-imap net-pop net-smtp - marcel (1.0.4) - matrix (0.4.2) + marcel (1.2.1) + matrix (0.4.3) method_source (1.1.0) - mime-types (3.5.2) - mime-types-data (~> 3.2015) - mime-types-data (3.2024.0702) - mimic (0.4.4) - json - plist (~> 3.1.0) - rack - sinatra - thin - mini_magick (4.13.2) + mime-types (3.7.0) + logger + mime-types-data (~> 3.2025, >= 3.2025.0507) + mime-types-data (3.2026.1103) mini_mime (1.1.5) - mini_racer (0.19.2) + mini_racer (0.22.2) libv8-node (~> 24.12.0.1) - minitest (5.24.1) - multi_json (1.15.0) - mustermann (3.0.0) - ruby2_keywords (~> 0.0.1) - mutex_m (0.2.0) - natcmp (1.4.3) - net-http (0.8.0) + minitest (6.1.0) + drb (~> 2.0) + prism (>= 1.5, < 3) + natcmp (1.4.4) + net-http (0.10.0) + net-protocol (>= 0.3.0) uri (>= 0.11.1) - net-imap (0.4.14) + net-imap (0.6.8) date net-protocol net-pop (0.1.2) net-protocol - net-protocol (0.2.2) + net-protocol (0.4.0) timeout - net-smtp (0.5.0) + net-smtp (0.5.2) net-protocol netrc (0.11.0) - newrelic_rpm (9.12.0) - nio4r (2.7.3) - nokogiri (1.16.7-aarch64-linux) + newrelic_rpm (10.9.1) + logger + nio4r (2.7.5) + nokogiri (1.19.7-aarch64-linux-gnu) racc (~> 1.4) - nokogiri (1.16.7-arm-linux) + nokogiri (1.19.7-aarch64-linux-musl) racc (~> 1.4) - nokogiri (1.16.7-arm64-darwin) + nokogiri (1.19.7-arm64-darwin) racc (~> 1.4) - nokogiri (1.16.7-x86-linux) + nokogiri (1.19.7-x86_64-darwin) racc (~> 1.4) - nokogiri (1.16.7-x86_64-darwin) + nokogiri (1.19.7-x86_64-linux-gnu) racc (~> 1.4) - nokogiri (1.16.7-x86_64-linux) + nokogiri (1.19.7-x86_64-linux-musl) racc (~> 1.4) - parallel (1.25.1) - parser (3.3.4.0) + ostruct (0.6.3) + parallel (2.3.0) + parser (3.3.13.0) ast (~> 2.4.1) racc pdfkit (0.8.7.3) - pg (1.5.7) - pghero (3.5.0) - activerecord (>= 6) - plist (3.1.0) - poltergeist (1.18.1) - capybara (>= 2.1, < 4) - cliver (~> 0.3.1) - websocket-driver (>= 0.2.0) + pg (1.7.0-aarch64-linux) + pg (1.7.0-aarch64-linux-musl) + pg (1.7.0-arm64-darwin) + pg (1.7.0-x86_64-darwin) + pg (1.7.0-x86_64-linux) + pg (1.7.0-x86_64-linux-musl) + pghero (4.1.0) + activerecord (>= 7.2) popper_js (1.16.1) - pry (0.14.2) + pp (0.6.4) + prettyprint + prettyprint (0.2.1) + prism (1.9.0) + pry (0.16.1) coderay (~> 1.1) method_source (~> 1.0) - pry-byebug (3.10.1) - byebug (~> 11.0) - pry (>= 0.13, < 0.15) + reline (>= 0.6.0) + pry-byebug (3.12.0) + byebug (~> 13.0) + pry (>= 0.13, < 0.17) pry-rails (0.3.11) pry (>= 0.13.0) - psych (5.1.2) - stringio - public_suffix (6.0.1) - puma (6.4.2) + public_suffix (7.0.5) + puma (8.0.2) nio4r (~> 2.0) - racc (1.8.0) - rack (2.2.9) - rack-attack (6.7.0) + racc (1.8.1) + rack (3.2.8) + rack-attack (6.8.0) rack (>= 1.0, < 4) - rack-cors (2.0.2) - rack (>= 2.0.0) - rack-mini-profiler (3.3.1) + rack-cors (3.0.0) + logger + rack (>= 3.0.14) + rack-mini-profiler (5.0.0) rack (>= 1.2.0) - rack-protection (3.2.0) + rack-session (2.1.2) base64 (>= 0.1.0) - rack (~> 2.2, >= 2.2.4) - rack-session (1.0.2) - rack (< 3) - rack-test (2.1.0) + rack (>= 3.0.0) + rack-test (2.3.0) rack (>= 1.3) rack-timeout (0.7.0) - rackup (1.0.0) - rack (< 3) - webrick + rackup (2.3.1) + rack (>= 3) railroady (1.6.0) - rails (7.1.3.4) - actioncable (= 7.1.3.4) - actionmailbox (= 7.1.3.4) - actionmailer (= 7.1.3.4) - actionpack (= 7.1.3.4) - actiontext (= 7.1.3.4) - actionview (= 7.1.3.4) - activejob (= 7.1.3.4) - activemodel (= 7.1.3.4) - activerecord (= 7.1.3.4) - activestorage (= 7.1.3.4) - activesupport (= 7.1.3.4) + rails (8.1.4.1) + actioncable (= 8.1.4.1) + actionmailbox (= 8.1.4.1) + actionmailer (= 8.1.4.1) + actionpack (= 8.1.4.1) + actiontext (= 8.1.4.1) + actionview (= 8.1.4.1) + activejob (= 8.1.4.1) + activemodel (= 8.1.4.1) + activerecord (= 8.1.4.1) + activestorage (= 8.1.4.1) + activesupport (= 8.1.4.1) bundler (>= 1.15.0) - railties (= 7.1.3.4) + railties (= 8.1.4.1) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) activesupport (>= 5.0.1.rc1) - rails-dom-testing (2.2.0) + rails-dom-testing (2.3.0) activesupport (>= 5.0.0) minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.6.0) - loofah (~> 2.21) - nokogiri (~> 1.14) - railties (7.1.3.4) - actionpack (= 7.1.3.4) - activesupport (= 7.1.3.4) - irb + rails-html-sanitizer (1.7.2) + loofah (~> 2.25, >= 2.25.3) + 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) + railties (8.1.4.1) + actionpack (= 8.1.4.1) + activesupport (= 8.1.4.1) + 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) - rake (13.2.1) - rdoc (6.7.0) - psych (>= 4.0.0) - readthis (2.2.0) - connection_pool (~> 2.1) - redis (>= 3.0, < 5.0) - redis (4.8.1) - regexp_parser (2.9.2) - reline (0.5.9) + rake (13.4.2) + rbi (0.5.0) + prism (~> 1.0) + rbs (>= 4.0.1) + rbs (4.2.0) + logger + prism (>= 1.6.0) + tsort + rdoc (8.1.0) + erb + prism (>= 1.6.0) + rbs (>= 4.0.0) + tsort + redis (6.0.0) + redis-client (= 0.30.1) + redis-client (0.30.1) + connection_pool + regexp_parser (2.13.1) + reline (0.7.0) io-console (~> 0.5) request_store (1.7.0) rack (>= 1.4) - responders (3.1.1) - actionpack (>= 5.2) - railties (>= 5.2) + require-hooks (0.5.1) + responders (3.2.1) + actionpack (>= 7.0) + railties (>= 7.0) rest-client (2.1.0) http-accept (>= 1.7.0, < 2.0) http-cookie (>= 1.0.2, < 2.0) mime-types (>= 1.16, < 4.0) netrc (~> 0.8) - rexml (3.3.2) - strscan - rspec (3.13.0) + rexml (3.4.4) + rspec (3.13.2) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) rspec-mocks (~> 3.13.0) - rspec-activemodel-mocks (1.2.0) + rspec-activemodel-mocks (1.3.0) activemodel (>= 3.0) activesupport (>= 3.0) rspec-mocks (>= 2.99, < 4.0) - rspec-core (3.13.0) + rspec-core (3.13.7) rspec-support (~> 3.13.0) - rspec-expectations (3.13.1) + rspec-expectations (3.13.6) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-mocks (3.13.1) + rspec-mocks (3.13.9) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-rails (6.1.3) - actionpack (>= 6.1) - activesupport (>= 6.1) - railties (>= 6.1) - rspec-core (~> 3.13) - rspec-expectations (~> 3.13) - rspec-mocks (~> 3.13) - rspec-support (~> 3.13) - rspec-support (3.13.1) - rubocop (1.65.0) - json (~> 2.3) - language_server-protocol (>= 3.17.0) - parallel (~> 1.10) + rspec-rails (8.1.0) + actionpack (>= 7.2) + activesupport (>= 7.2) + railties (>= 7.2) + rspec-core (>= 3.13.0, < 5.0.0) + rspec-expectations (>= 3.13.0, < 5.0.0) + rspec-mocks (>= 3.13.0, < 5.0.0) + rspec-support (>= 3.13.0, < 5.0.0) + rspec-support (3.13.8) + rubocop (1.92.2) + json (>= 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (>= 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 2.4, < 3.0) - rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.31.1, < 2.0) + regexp_parser (>= 2.11.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.31.3) - parser (>= 3.3.1.0) - rubocop-md (1.2.2) - rubocop (>= 1.0) - rubocop-minitest (0.35.1) - rubocop (>= 1.61, < 2.0) - rubocop-ast (>= 1.31.1, < 2.0) - rubocop-packaging (0.5.2) - rubocop (>= 1.33, < 2.0) - rubocop-performance (1.21.1) - rubocop (>= 1.48.1, < 2.0) - rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rails (2.25.1) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.50.0) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-capybara (3.1.0) + lint_roller (~> 1.1) + rubocop (~> 1.89) + rubocop-factory_bot (2.28.0) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-md (3.0.0) + kramdown (~> 2.4) + kramdown-parser-gfm (~> 1.1) + lint_roller (~> 1.1) + rubocop (>= 1.72.1) + rubocop-minitest (0.41.0) + lint_roller (~> 1.1) + rubocop (>= 1.89.0, < 2.0) + rubocop-ast (>= 1.38.0, < 2.0) + rubocop-packaging (0.6.0) + lint_roller (~> 1.1.0) + rubocop (>= 1.72.1, < 2.0) + rubocop-performance (1.27.0) + lint_roller (~> 1.1) + rubocop (>= 1.89.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rails (2.38.0) activesupport (>= 4.2.0) + lint_roller (~> 1.1) rack (>= 1.1) - rubocop (>= 1.33.0, < 2.0) - rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rails_config (1.16.0) - rubocop (>= 1.57.0) - rubocop-ast (>= 1.26.0) + rubocop (>= 1.89.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) + rubocop-rails_config (1.17.7) + rubocop (>= 1.74.0) + rubocop-ast (>= 1.38.0) rubocop-md - rubocop-minitest (~> 0.22) - rubocop-packaging (~> 0.5) - rubocop-performance (~> 1.11) - rubocop-rails (~> 2.0) - ruby-prof (1.7.0) + rubocop-minitest (~> 0.37) + rubocop-packaging (~> 0.6) + rubocop-performance (~> 1.24) + rubocop-rails (~> 2.30) + rubocop-rspec (3.10.2) + lint_roller (~> 1.1) + regexp_parser (>= 2.0) + rubocop (~> 1.86, >= 1.86.2) + rubocop-sorbet (0.17.2) + rbi (~> 0.4, >= 0.4.3) + rbs (>= 4.1.1) + spoom (~> 1.8) + rubocop-thread_safety (0.8.0) + lint_roller (~> 1.1) + rubocop (~> 1.89) + rubocop-ast (>= 1.44.0, < 2.0) ruby-progressbar (1.13.0) - ruby-vips (2.2.2) + ruby-vips (2.3.0) ffi (~> 1.12) logger - ruby2_keywords (0.0.5) - sassc (2.4.0) - ffi (~> 1.9) - sassc-rails (2.1.2) - railties (>= 4.0.0) - sassc (>= 2.0) - sprockets (> 3.0) - sprockets-rails - tilt - simplecov (0.22.0) - docile (~> 1.1) - simplecov-html (~> 0.11) - simplecov_json_formatter (~> 0.1) - simplecov-html (0.12.3) - simplecov_json_formatter (0.1.4) - sinatra (3.2.0) - mustermann (~> 3.0) - rack (~> 2.2, >= 2.2.4) - rack-protection (= 3.2.0) - tilt (~> 2.0) - sprockets (4.2.1) - concurrent-ruby (~> 1.0) + rubydex (0.4.4-aarch64-linux) + rubydex (0.4.4-arm64-darwin) + rubydex (0.4.4-x86_64-darwin) + rubydex (0.4.4-x86_64-linux) + sass-embedded (1.108.1-aarch64-linux-gnu) + google-protobuf (~> 4.31) + sass-embedded (1.108.1-aarch64-linux-musl) + google-protobuf (~> 4.31) + sass-embedded (1.108.1-arm64-darwin) + google-protobuf (~> 4.31) + sass-embedded (1.108.1-x86_64-darwin) + google-protobuf (~> 4.31) + sass-embedded (1.108.1-x86_64-linux-gnu) + google-protobuf (~> 4.31) + sass-embedded (1.108.1-x86_64-linux-musl) + google-protobuf (~> 4.31) + sassc-embedded (1.80.10) + sass-embedded (~> 1.80) + securerandom (0.4.1) + simplecov (1.3.2) + sorbet (0.6.13640) + sorbet-static (= 0.6.13640) + sorbet-runtime (0.6.13640) + sorbet-static (0.6.13640-aarch64-linux) + sorbet-static (0.6.13640-universal-darwin) + sorbet-static (0.6.13640-x86_64-linux) + sorbet-static-and-runtime (0.6.13640) + sorbet (= 0.6.13640) + sorbet-runtime (= 0.6.13640) + spoom (1.8.13) + erubi (>= 1.10.0) + prism (>= 0.28.0) + rbi (>= 0.4.2) + rbs (>= 4.0.0.dev.5) + rexml (>= 3.2.6) + sorbet-static-and-runtime (>= 0.5.10187) + thor (>= 0.19.2) + sprockets (4.4.1) + concurrent-ruby (~> 1.1) + logger rack (>= 2.2.4, < 4) - sprockets-rails (3.5.1) + sprockets-rails (3.5.2) actionpack (>= 6.1) activesupport (>= 6.1) sprockets (>= 3.0.0) - stringio (3.1.1) - strscan (3.1.0) swagger-blocks (3.0.0) - thin (1.8.2) - daemons (~> 1.0, >= 1.0.9) - eventmachine (~> 1.0, >= 1.0.4) - rack (>= 1, < 3) - thor (1.3.1) - tilt (2.4.0) - timeout (0.4.1) + tapioca (0.20.0) + benchmark + bundler (>= 2.2.25) + netrc (>= 0.11.0) + parallel (>= 1.21.0) + rbi (>= 0.4.1) + require-hooks (>= 0.2.2) + rubydex (>= 0.1.0.beta10) + sorbet-static-and-runtime (>= 0.6.12698) + spoom (>= 1.7.16) + thor (>= 1.2.0) + tsort + thor (1.5.0) + tilt (2.9.0) + timeout (0.6.1) + tsort (0.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - uglifier (4.2.0) + uglifier (4.2.1) execjs (>= 0.3.0, < 3) - unicode-display_width (2.5.0) - uri (1.1.1) - webrick (1.8.1) - websocket-driver (0.7.6) + unicode-display_width (3.3.0) + unicode-emoji (~> 4.3) + unicode-emoji (4.3.0) + uri (1.1.2) + useragent (0.16.11) + websocket-driver (0.8.2) + base64 websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) xml-simple (1.1.9) rexml xpath (3.2.0) nokogiri (~> 1.8) - zeitwerk (2.6.16) + zeitwerk (2.8.3) PLATFORMS aarch64-linux aarch64-linux aarch64-linux-musl - arm-linux - arm-linux - arm-linux-musl arm64-darwin - x86-linux - x86-linux - x86-linux-musl x86_64-darwin x86_64-linux x86_64-linux-musl DEPENDENCIES - activerecord-import (~> 1.7) - activerecord-session_store (~> 2.1) - argon2 (~> 2.3) + activerecord-import (~> 3.0) + activerecord-session_store (~> 2.3) + argon2 (~> 2.3, >= 2.3.3) + benchmark (~> 0.5.0) bootstrap (~> 4.6) - brakeman (~> 6.1, >= 6.1.2) + brakeman (~> 8.1) bundler-audit (~> 0.9.1) - cancancan (~> 3.6, >= 3.6.1) + cancancan (~> 3.7) capybara (~> 3.40) - cgi (~> 0.3.6) - database_cleaner (~> 2.0, >= 2.0.2) - doorkeeper (~> 5.7, >= 5.7.1) - doorkeeper-openid_connect (~> 1.8, >= 1.8.9) - factory_bot_rails (~> 6.4, >= 6.4.3) - faraday (~> 2.14) + cgi (~> 0.5) + csv (~> 3.3, >= 3.3.7) + cuprite (~> 0.20) + dartsass-sprockets (~> 3.2) + database_cleaner (~> 2.1) + doorkeeper (~> 5.9, >= 5.9.9) + doorkeeper-openid_connect (~> 2.1) + factory_bot_rails (~> 6.5) + faraday (~> 2.15) file_validators (~> 3.0) - font-awesome-rails (~> 4.7, >= 4.7.0.8) + font-awesome-rails (~> 4.7, >= 4.7.0.9) gravtastic (~> 3.2, >= 3.2.6) - hiredis (~> 0.6.3) - image_processing (~> 1.12, >= 1.12.2) - irb (~> 1.14) - jquery-rails (~> 4.6) - json-schema (~> 4.3) - launchy (~> 3.0, >= 3.0.1) + image_processing (~> 2.2) + irb (~> 1.18) + jquery-rails (~> 4.6, >= 4.6.1) + json-schema (~> 6.2) + launchy (~> 3.1) letter_opener (~> 1.10) - logstasher (~> 2.1, >= 2.1.5) - mimic (~> 0.4.4) - mini_racer (~> 0.19.2) - natcmp (~> 1.4, >= 1.4.3) - newrelic_rpm (~> 9.11) + logstasher (~> 3.0) + mini_racer (~> 0.22) + natcmp (~> 1.4, >= 1.4.4) + newrelic_rpm (~> 10.9) + ostruct (~> 0.6.3) pdfkit (~> 0.8.7.3) - pg (~> 1.5, >= 1.5.6) - pghero (~> 3.5) - poltergeist (~> 1.18, >= 1.18.1) - pry (~> 0.14.2) - pry-byebug (~> 3.10, >= 3.10.1) + pg (~> 1.7) + pghero (~> 4.1) + pry (~> 0.16) + pry-byebug (~> 3.12) pry-rails (~> 0.3.11) - puma (~> 6.4, >= 6.4.2) - rack-attack (~> 6.7) - rack-cors (~> 2.0, >= 2.0.2) - rack-mini-profiler (~> 3.3, >= 3.3.1) + puma (~> 8.0) + rack-attack (~> 6.8) + rack-cors (~> 3.0) + rack-mini-profiler (~> 5.0) rack-timeout (~> 0.7.0) railroady (~> 1.6) - rails (~> 7.1, >= 7.1.3.4) + rails (~> 8.1.4) rails-controller-testing (~> 1.0, >= 1.0.5) rake (~> 13.2, >= 13.2.1) - readthis (~> 2.2) - redis (~> 4.5) - responders (~> 3.1, >= 3.1.1) + redis (~> 6.0) + responders (~> 3.2) rest-client (~> 2.1) rspec (~> 3.13) - rspec-activemodel-mocks (~> 1.2) + rspec-activemodel-mocks (~> 1.3) rspec-core (~> 3.13) - rspec-rails (~> 6.1, >= 6.1.3) - rubocop (~> 1.65) - rubocop-rails_config (~> 1.16) - ruby-prof (~> 1.7) - sassc-rails (~> 2.1, >= 2.1.2) - simplecov (~> 0.22.0) - sprockets-rails (~> 3.5, >= 3.5.1) + rspec-rails (~> 8.1) + rubocop (~> 1.92) + rubocop-capybara (~> 3.1) + rubocop-factory_bot (~> 2.28) + rubocop-performance (~> 1.27) + rubocop-rails (~> 2.38) + rubocop-rails_config (~> 1.17) + rubocop-rspec (~> 3.10) + rubocop-sorbet (~> 0.17) + rubocop-thread_safety (~> 0.8) + ruby-vips (~> 2.3) + simplecov (~> 1.3) + sorbet (~> 0.6.13640) + sorbet-runtime (~> 0.6.13640) + spoom (~> 1.8) + sprockets-rails (~> 3.5, >= 3.5.2) swagger-blocks (~> 3.0) - thin (~> 1.8, >= 1.8.2) - uglifier (~> 4.2) + tapioca (~> 0.20.0) + uglifier (~> 4.2, >= 4.2.1) xml-simple (~> 1.1, >= 1.1.9) BUNDLED WITH - 2.5.21 + 4.0.20 diff --git a/Installation.md b/Installation.md index 17fac6fbe..802fcf80b 100644 --- a/Installation.md +++ b/Installation.md @@ -14,7 +14,7 @@ sudo apt-get update ``` TMC-server dependencies ```bash -sudo apt-get install git build-essential zip unzip imagemagick maven make phantomjs bc postgresql postgresql-contrib chrpath libssl-dev libxft-dev libfreetype6 libfreetype6-dev libfontconfig1 libfontconfig1-dev xfonts-75dpi libpq-dev +sudo apt-get install git build-essential zip unzip libvips42 maven make chromium bc postgresql postgresql-contrib chrpath libssl-dev libxft-dev libfreetype6 libfreetype6-dev libfontconfig1 libfontconfig1-dev xfonts-75dpi libpq-dev ``` Ruby dependencies ```bash diff --git a/app/assets/stylesheets/organizations.scss b/app/assets/stylesheets/organizations.scss index 6fcaf6938..97268b89a 100644 --- a/app/assets/stylesheets/organizations.scss +++ b/app/assets/stylesheets/organizations.scss @@ -27,8 +27,8 @@ $image-size: 6rem; .small-organization-card .card-body img { margin-right: 1rem; - width: $image-size/2; - height: $image-size/2; + width: $image-size * 0.5; + height: $image-size * 0.5; } .cover-content { diff --git a/app/background_tasks/kafka_batch_update_points_task.rb b/app/background_tasks/kafka_batch_update_points_task.rb index f902811da..69fcb2495 100644 --- a/app/background_tasks/kafka_batch_update_points_task.rb +++ b/app/background_tasks/kafka_batch_update_points_task.rb @@ -1,19 +1,23 @@ +# typed: strict # frozen_string_literal: true require 'kafka_updater' class KafkaBatchUpdatePointsTask + extend T::Sig + sig { void } def initialize - @kafka_bridge_url = SiteSetting.value('kafka_bridge_url') - @kafka_bridge_secret = SiteSetting.value('kafka_bridge_secret') - @service_id = SiteSetting.value('moocfi_service_id') - @updater = KafkaUpdater.new(@kafka_bridge_url, @kafka_bridge_secret, @service_id) + @kafka_bridge_url = T.let(SiteSetting.value('kafka_bridge_url'), T.nilable(String)) + @kafka_bridge_secret = T.let(SiteSetting.value('kafka_bridge_secret'), T.nilable(String)) + @service_id = T.let(SiteSetting.value('moocfi_service_id'), T.nilable(String)) + @updater = T.let(KafkaUpdater.new(@kafka_bridge_url, @kafka_bridge_secret, @service_id), KafkaUpdater) end + sig { void } def run return unless @kafka_bridge_url && @kafka_bridge_secret && @service_id return if @kafka_bridge_url.empty? || @kafka_bridge_secret.empty? || @service_id.empty? - KafkaBatchUpdatePoints.where(realtime: false).each do |task| + KafkaBatchUpdatePoints.where(realtime: false).find_each do |task| finished_successfully = false begin type = @updater.task_type(task) @@ -35,12 +39,14 @@ def run end rescue => e Rails.logger.error("Task failed: #{e.class.name}: #{e.message}") - Rails.logger.error("Task failed backtrace: #{e.backtrace.join("\n")}") if e.backtrace + backtrace = e.backtrace + Rails.logger.error("Task failed backtrace: #{backtrace.join("\n")}") if backtrace end task.destroy! if finished_successfully end end + sig { returns(Integer) } def wait_delay 5 end diff --git a/app/background_tasks/kafka_realtime_update_points_task.rb b/app/background_tasks/kafka_realtime_update_points_task.rb index 4b18631c6..9099e8bc0 100644 --- a/app/background_tasks/kafka_realtime_update_points_task.rb +++ b/app/background_tasks/kafka_realtime_update_points_task.rb @@ -1,19 +1,23 @@ +# typed: strict # frozen_string_literal: true require 'kafka_updater' class KafkaRealtimeUpdatePointsTask + extend T::Sig + sig { void } def initialize - @kafka_bridge_url = SiteSetting.value('kafka_bridge_url') - @kafka_bridge_secret = SiteSetting.value('kafka_bridge_secret') - @service_id = SiteSetting.value('moocfi_service_id') - @updater = KafkaUpdater.new(@kafka_bridge_url, @kafka_bridge_secret, @service_id) + @kafka_bridge_url = T.let(SiteSetting.value('kafka_bridge_url'), T.nilable(String)) + @kafka_bridge_secret = T.let(SiteSetting.value('kafka_bridge_secret'), T.nilable(String)) + @service_id = T.let(SiteSetting.value('moocfi_service_id'), T.nilable(String)) + @updater = T.let(KafkaUpdater.new(@kafka_bridge_url, @kafka_bridge_secret, @service_id), KafkaUpdater) end + sig { void } def run return unless @kafka_bridge_url && @kafka_bridge_secret && @service_id return if @kafka_bridge_url.empty? || @kafka_bridge_secret.empty? || @service_id.empty? - KafkaBatchUpdatePoints.where(realtime: true).each do |task| + KafkaBatchUpdatePoints.where(realtime: true).find_each do |task| finished_successfully = false begin type = @updater.task_type(task) @@ -35,12 +39,14 @@ def run end rescue => e Rails.logger.error("Task failed: #{e.class.name}: #{e.message}") - Rails.logger.error("Task failed backtrace: #{e.backtrace.join("\n")}") if e.backtrace + backtrace = e.backtrace + Rails.logger.error("Task failed backtrace: #{backtrace.join("\n")}") if backtrace end task.destroy! if finished_successfully end end + sig { returns(Integer) } def wait_delay 5 end diff --git a/app/background_tasks/refresh_course_task.rb b/app/background_tasks/refresh_course_task.rb index 230194e1d..b9c2152b3 100644 --- a/app/background_tasks/refresh_course_task.rb +++ b/app/background_tasks/refresh_course_task.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require 'rust_langs_cli_executor' @@ -5,11 +6,14 @@ require 'course_refresh_database_updater' class RefreshCourseTask + extend T::Sig + sig { void } def initialize end + sig { void } def run - CourseTemplateRefresh.where(status: :not_started).each do |task| + CourseTemplateRefresh.where(status: :not_started).find_each do |task| channel_id = task.course_template_id task.status = :in_progress task.save! @@ -23,45 +27,47 @@ def run broadcast_to_channel(channel_id, 'Updating database', 0.95, '-') # Should we do this loop in CourseRefreshDatabaseUpdater? - courses.each do |course| - @refresh = CourseRefreshDatabaseUpdater.new.refresh_course(course, rust_output) - end + refresh = courses.map { |course| CourseRefreshDatabaseUpdater.new.refresh_course(course, rust_output) }.last broadcast_to_channel(channel_id, 'Generating refresh report', 0.98, '-') - CourseTemplateRefreshReport.create(course_template_refresh_id: task.id, refresh_errors: @refresh.errors, refresh_warnings: @refresh.warnings, refresh_notices: @refresh.notices, refresh_timings: @refresh.timings) + raise 'Template has no courses' unless refresh + CourseTemplateRefreshReport.create(course_template_refresh_id: task.id, refresh_errors: refresh.errors, refresh_warnings: refresh.warnings, refresh_notices: refresh.notices, refresh_timings: refresh.timings) broadcast_to_channel(channel_id, 'Cleaning up cache', 0.99, '-') if File.directory?(task.langs_refresh_output['output-data']['new-cache-path']) - old_cache_path = task.course_template.cache_path - task.course_template.increment_cached_version - task.course_template.save! - task.course_template.reload + course_template = T.must(task.course_template) + old_cache_path = course_template.cache_path + course_template.increment_cached_version + course_template.save! + course_template.reload courses.each(&:save!) FileUtils.rm_rf(old_cache_path) end broadcast_to_channel(channel_id, 'Refresh completed', 1, '-', task.id) task.status = :complete - task.percent_done = 1 + task.percent_done = BigDecimal('1') task.save! rescue => e Rails.logger.error("Course Refresh task #{task.id} failed:#{e}") - Rails.logger.error(e.backtrace.join("\n")) - CourseTemplateRefreshReport.create(course_template_refresh_id: task.id, refresh_errors: [e.backtrace.join("\n")], refresh_warnings: [], refresh_notices: [], refresh_timings: {}) - broadcast_to_channel(channel_id, 'Refresh crashed', 0, '-', task.id) + Rails.logger.error(T.must(e.backtrace).join("\n")) + CourseTemplateRefreshReport.create(course_template_refresh_id: task.id, refresh_errors: [T.must(e.backtrace).join("\n")], refresh_warnings: [], refresh_notices: [], refresh_timings: {}) + broadcast_to_channel(task.course_template_id, 'Refresh crashed', 0, '-', task.id) task.status = :crashed - task.percent_done = 0 + task.percent_done = BigDecimal('0') task.create_phase(e, 0) task.save! end end + sig { returns(Integer) } def wait_delay 5 end private + sig { params(id: Integer, msg: String, percent: Numeric, time: String, refresh_id: T.nilable(Integer)).void } def broadcast_to_channel(id, msg, percent, time, refresh_id = nil) Rails.logger.info("Broadcasting message: #{msg} to channel CourseTemplateRefreshChannel-#{id}, Percent done: #{percent}, Time: #{time}, Course Template Refresh ID: #{refresh_id}") ActionCable.server.broadcast("CourseTemplateRefreshChannel-#{id}", { diff --git a/app/background_tasks/rust_langs_downloader_task.rb b/app/background_tasks/rust_langs_downloader_task.rb index 2f9e561f0..868a563c8 100644 --- a/app/background_tasks/rust_langs_downloader_task.rb +++ b/app/background_tasks/rust_langs_downloader_task.rb @@ -1,13 +1,17 @@ +# typed: strict # frozen_string_literal: true require 'rust_langs_cli_executor' class RustLangsDownloaderTask - @@rust_langs_version = '0.39.8' + extend T::Sig + @@rust_langs_version = T.let('0.39.8', String) + sig { void } def initialize end + sig { void } def run Rails.logger.info('Creating directory vendor/tmc-langs-rust') unless File.directory?('vendor/tmc-langs-rust') Dir.mkdir('vendor/tmc-langs-rust') unless File.directory?('vendor/tmc-langs-rust') @@ -46,6 +50,7 @@ def run end end + sig { returns(Integer) } def wait_delay 1 end diff --git a/app/background_tasks/sandbox_status_fetcher_task.rb b/app/background_tasks/sandbox_status_fetcher_task.rb index 8332aa12f..f707c3849 100644 --- a/app/background_tasks/sandbox_status_fetcher_task.rb +++ b/app/background_tasks/sandbox_status_fetcher_task.rb @@ -1,16 +1,21 @@ +# typed: strict # frozen_string_literal: true require 'json' require 'benchmark' class SandboxStatusFetcherTask + extend T::Sig + + sig { void } def initialize - @sandboxes = RemoteSandbox.all + RemoteSandbox.all_experimental + @sandboxes = T.let(RemoteSandbox.all + RemoteSandbox.all_experimental, T::Array[RemoteSandbox]) end + sig { void } def run - data = @sandboxes.map do |sandbox| - data = nil + statuses = @sandboxes.map do |sandbox| + data = T.let({}, T::Hash[Symbol, T.untyped]) fetching_time = Benchmark.measure do data = { baseurl: sandbox.baseurl, @@ -21,9 +26,10 @@ def run data[:fetching_time] = fetching_time.real.round(2) data end - Rails.cache.write('sandbox-status-cache', data.to_json, expires_in: 1.minute) + Rails.cache.write('sandbox-status-cache', statuses.to_json, expires_in: 1.minute) end + sig { returns(Integer) } def wait_delay 1 end diff --git a/app/background_tasks/stats_fetcher_task.rb b/app/background_tasks/stats_fetcher_task.rb index 44def18bd..b81b6891d 100644 --- a/app/background_tasks/stats_fetcher_task.rb +++ b/app/background_tasks/stats_fetcher_task.rb @@ -1,17 +1,21 @@ +# typed: strict # frozen_string_literal: true class StatsFetcherTask + extend T::Sig + sig { void } def initialize end + sig { void } def run data = { high_priority_submissions_count: Submission.to_be_reprocessed.where(processing_priority: 0).count, sandbox_queue_length: Submission.to_be_reprocessed.count, unprocessed_submissions_count: Submission.where(processed: false).count, - submissions_count_minute: Submission.where(created_at: (Time.current - 1.minute)..Time.current).count, - submissions_count_five_minutes: Submission.where(created_at: (Time.current - 5.minute)..Time.current).count, - submissions_count_hour: Submission.where(created_at: (Time.current - 1.hour)..Time.current).count, + submissions_count_minute: Submission.where(created_at: 1.minute.ago..Time.current).count, + submissions_count_five_minutes: Submission.where(created_at: 5.minutes.ago..Time.current).count, + submissions_count_hour: Submission.where(created_at: 1.hour.ago..Time.current).count, submissions_count_today: Submission.where(created_at: Time.current.all_day).count, submissions_count_yesterday: Submission.where(created_at: Time.current.yesterday.all_day).count, submissions_count_week: Submission.where(created_at: Time.current.all_week).count, @@ -19,6 +23,7 @@ def run Rails.cache.write('submission-stats-cache', data.to_json, expires_in: 1.minute) end + sig { returns(Integer) } def wait_delay 5 end diff --git a/app/background_tasks/submission_long_queue_processor_task.rb b/app/background_tasks/submission_long_queue_processor_task.rb index 623db7b1e..60567a86e 100644 --- a/app/background_tasks/submission_long_queue_processor_task.rb +++ b/app/background_tasks/submission_long_queue_processor_task.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require 'submission_processor' @@ -6,10 +7,13 @@ # submissions to sandbox. This task uses reverse order in order to avoid # sending the same submission to sandbox multiple times class SubmissionLongQueueProcessorTask + extend T::Sig + sig { void } def initialize - @processor = SubmissionProcessor.new + @processor = T.let(SubmissionProcessor.new, SubmissionProcessor) end + sig { void } def run queue = Submission.to_be_reprocessed.where(processing_priority: 0).order(:created_at).reverse_order return if queue.length <= 10 @@ -21,6 +25,7 @@ def run end end + sig { returns(Integer) } def wait_delay 1 end diff --git a/app/background_tasks/submission_processor_task.rb b/app/background_tasks/submission_processor_task.rb index a67fc408e..e10da7940 100644 --- a/app/background_tasks/submission_processor_task.rb +++ b/app/background_tasks/submission_processor_task.rb @@ -1,16 +1,21 @@ +# typed: strict # frozen_string_literal: true require 'submission_processor' class SubmissionProcessorTask + extend T::Sig + sig { void } def initialize - @processor = SubmissionProcessor.new + @processor = T.let(SubmissionProcessor.new, SubmissionProcessor) end + sig { void } def run @processor.process_some_submissions end + sig { returns(Float) } def wait_delay 0.1 end diff --git a/app/background_tasks/uncomputed_unlock_computor_task.rb b/app/background_tasks/uncomputed_unlock_computor_task.rb index dae4f5275..d1b1fab15 100644 --- a/app/background_tasks/uncomputed_unlock_computor_task.rb +++ b/app/background_tasks/uncomputed_unlock_computor_task.rb @@ -1,20 +1,25 @@ +# typed: strict # frozen_string_literal: true require 'submission_processor' class UncomputedUnlockComputorTask + extend T::Sig + sig { void } def initialize end + sig { void } def run - UncomputedUnlock.order('id ASC').limit(1).each do |uncomputed_unlock| - course = uncomputed_unlock.course - user = uncomputed_unlock.user + UncomputedUnlock.order(:id).limit(1).each do |uncomputed_unlock| + course = T.must(uncomputed_unlock.course) + user = T.must(uncomputed_unlock.user) Rails.logger.info "Calculating unlocks for user #{user.id} and course #{course.name}. Queue length: #{UncomputedUnlock.count}." Unlock.refresh_unlocks(course, user) end end + sig { returns(Float) } def wait_delay 0.1 end diff --git a/app/background_tasks/uncomputed_unlock_computor_task_backup.rb b/app/background_tasks/uncomputed_unlock_computor_task_backup.rb index b0d0790b3..2e84b4887 100644 --- a/app/background_tasks/uncomputed_unlock_computor_task_backup.rb +++ b/app/background_tasks/uncomputed_unlock_computor_task_backup.rb @@ -1,25 +1,29 @@ +# typed: strict # frozen_string_literal: true require 'submission_processor' class UncomputedUnlockComputorTaskBackup + extend T::Sig + sig { void } def initialize end + sig { returns(T.nilable(T::Array[Thread])) } def run count = UncomputedUnlock.count return unless count > 3 threads = [7, count - 3].min limit = [threads * 5, count - 3].min - a = UncomputedUnlock.order('id DESC').limit(limit) + a = UncomputedUnlock.order(id: :desc).limit(limit) Rails.logger.info "Creating #{threads} threads to compute unlocks." workers = a.to_a.each_slice((a.size / threads.to_f).round).map do |uncomputed_unlocks| Thread.new do uncomputed_unlocks.each do |uncomputed_unlock| - course = uncomputed_unlock.course - user = uncomputed_unlock.user + course = T.must(uncomputed_unlock.course) + user = T.must(uncomputed_unlock.user) Rails.logger.info "Calculating unlocks for user #{user.id} and course #{course.name} with a backup task. Queue length: #{UncomputedUnlock.count}." Unlock.refresh_unlocks(course, user) end @@ -28,6 +32,7 @@ def run workers.map(&:join) end + sig { returns(Float) } def wait_delay 0.1 end diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb index 9aec23053..246da394a 100644 --- a/app/channels/application_cable/channel.rb +++ b/app/channels/application_cable/channel.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module ApplicationCable diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb index 051daf8d0..bff654d7e 100644 --- a/app/channels/application_cable/connection.rb +++ b/app/channels/application_cable/connection.rb @@ -1,20 +1,21 @@ +# typed: strict # frozen_string_literal: true module ApplicationCable class Connection < ActionCable::Connection::Base + extend T::Sig + identified_by :current_user + sig { void } def connect self.current_user = find_verified_user end private + sig { returns(User) } def find_verified_user - if verified_user = User.find_by(id: @request.session[:user_id]) - verified_user - else - reject_unauthorized_connection - end + User.find_by(id: request.session[:user_id]) || reject_unauthorized_connection end end end diff --git a/app/channels/course_template_refresh_channel.rb b/app/channels/course_template_refresh_channel.rb index b2c2bfab5..69225d53a 100644 --- a/app/channels/course_template_refresh_channel.rb +++ b/app/channels/course_template_refresh_channel.rb @@ -1,10 +1,15 @@ +# typed: strict # frozen_string_literal: true class CourseTemplateRefreshChannel < ApplicationCable::Channel + extend T::Sig + + sig { void } def subscribed stream_from "CourseTemplateRefreshChannel-#{params[:courseTemplateId]}" end + sig { void } def unsubscribed # Any cleanup needed when channel is unsubscribed end diff --git a/app/controllers/api.rb b/app/controllers/api.rb index e30e81f73..603fc34c0 100644 --- a/app/controllers/api.rb +++ b/app/controllers/api.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api diff --git a/app/controllers/api/beta.rb b/app/controllers/api/beta.rb index 60e6e6359..07443f1a2 100644 --- a/app/controllers/api/beta.rb +++ b/app/controllers/api/beta.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api diff --git a/app/controllers/api/beta/base_controller.rb b/app/controllers/api/beta/base_controller.rb index 37699d18a..fee736024 100644 --- a/app/controllers/api/beta/base_controller.rb +++ b/app/controllers/api/beta/base_controller.rb @@ -1,8 +1,10 @@ +# typed: strict # frozen_string_literal: true require 'json' class Api::Beta::BaseController < ApplicationController + extend T::Sig clear_respond_to respond_to :json @@ -12,13 +14,16 @@ class Api::Beta::BaseController < ApplicationController skip_authorization_check # we use doorkeeper in api, so let's skip cancancan rescue_from CanCan::AccessDenied do |e| + T.bind(self, Api::Beta::BaseController) render json: errors_json(e.message), status: :forbidden end rescue_from ActiveRecord::RecordNotFound do |e| + T.bind(self, Api::Beta::BaseController) render json: errors_json(e.message), status: :not_found end + sig { params(hash: T.untyped).void } def present(hash) if !!params[:pretty] render json: JSON.pretty_generate(hash) @@ -28,9 +33,10 @@ def present(hash) end private + sig { void } def authenticate_user! if doorkeeper_token - @current_user = User.find(doorkeeper_token.resource_owner_id) + @current_user = T.let(User.find(doorkeeper_token.resource_owner_id), T.nilable(User)) end return if @current_user @@ -38,8 +44,13 @@ def authenticate_user! render json: { errors: ['User is not authenticated!'] }, status: :unauthorized end - attr_reader :current_user + # Before authenticate_user! has run (ApplicationController's own before_actions), this is a Guest. + sig { returns(User) } + def current_user + @current_user || Guest.new + end + sig { params(messages: T.any(String, T::Array[String])).returns(T::Hash[Symbol, T::Array[String]]) } def errors_json(messages) { errors: [*messages] } end diff --git a/app/controllers/api/beta/course_id_information_controller.rb b/app/controllers/api/beta/course_id_information_controller.rb index 5cdfa81b4..518351149 100644 --- a/app/controllers/api/beta/course_id_information_controller.rb +++ b/app/controllers/api/beta/course_id_information_controller.rb @@ -1,8 +1,10 @@ +# typed: strict # frozen_string_literal: true class Api::Beta::CourseIdInformationController < Api::Beta::BaseController before_action :doorkeeper_authorize!, scopes: [:public] + sig { void } def index course_ids = Course.order(:id).where(hidden: false).enabled.pluck(:id) present(course_ids) diff --git a/app/controllers/api/beta/participant_controller.rb b/app/controllers/api/beta/participant_controller.rb index 0fb47402f..5398320d0 100644 --- a/app/controllers/api/beta/participant_controller.rb +++ b/app/controllers/api/beta/participant_controller.rb @@ -1,15 +1,19 @@ +# typed: strict # frozen_string_literal: true require 'course_list' class Api::Beta::ParticipantController < Api::Beta::BaseController + extend T::Sig + before_action :doorkeeper_authorize!, scopes: [:public] + sig { void } def courses user = User.where(id: params[:id]).first || current_user authorize! :read, user courses = user.courses_with_submissions - list = CourseList.new(current_user, view_context).course_list_data_no_organisation(Course.find(user.course_ids)) + list = CourseList.new(current_user, view_context).course_list_data_no_organisation(Course.find(T.let(user.course_ids, T::Array[Numeric]))) list.each do |course| course[:exercises] = courses[course[:id]] end @@ -17,6 +21,7 @@ def courses present(list) end + sig { void } def index user = current_user present(username: user.login, diff --git a/app/controllers/api/beta/stats_controller.rb b/app/controllers/api/beta/stats_controller.rb index 8d530ebbd..fb6ccd1ad 100644 --- a/app/controllers/api/beta/stats_controller.rb +++ b/app/controllers/api/beta/stats_controller.rb @@ -1,8 +1,10 @@ +# typed: strict # frozen_string_literal: true class Api::Beta::StatsController < Api::Beta::BaseController before_action :doorkeeper_authorize!, scopes: [:public] + sig { void } def submission_processing_times processing_began_at = Submission.arel_table[:processing_began_at] results = Submission @@ -14,15 +16,16 @@ def submission_processing_times res = results.map do |result| { - avg_duration: result.avg_duration, - stddev: result.stddev, - minute: result.minute, - count: result.count + avg_duration: result[:avg_duration], + stddev: result[:stddev], + minute: result[:minute], + count: result[:count] } end present(res) end + sig { void } def submission_queue_times created_at = Submission.arel_table[:created_at] results = Submission @@ -30,10 +33,10 @@ def submission_queue_times res = results.map do |result| { - avg_duration: result.avg_duration, - stddev: result.stddev, - minute: result.minute, - count: result.count + avg_duration: result[:avg_duration], + stddev: result[:stddev], + minute: result[:minute], + count: result[:count] } end present(res) diff --git a/app/controllers/api/v8.rb b/app/controllers/api/v8.rb index ae08a53e7..ea0271e96 100644 --- a/app/controllers/api/v8.rb +++ b/app/controllers/api/v8.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api diff --git a/app/controllers/api/v8/apidocs_controller.rb b/app/controllers/api/v8/apidocs_controller.rb index f8b984310..8758e309e 100644 --- a/app/controllers/api/v8/apidocs_controller.rb +++ b/app/controllers/api/v8/apidocs_controller.rb @@ -1,8 +1,10 @@ +# typed: strict # frozen_string_literal: true module Api module V8 class ApidocsController < ActionController::Base + extend T::Sig include Swagger::Blocks swagger_root do @@ -150,7 +152,7 @@ class ApidocsController < ActionController::Base # A list of all classes that have swagger_* declarations. # The order of the swagered_classes affects the order they are shown in swaggerUI - SWAGGERED_CLASSES = [ + SWAGGERED_CLASSES = T.let([ Api::V8::UsersController, Api::V8::CoursesController, Api::V8::Courses::PointsController, @@ -197,8 +199,9 @@ class ApidocsController < ActionController::Base Submission, User, self - ].freeze + ].freeze, T::Array[T::Class[T.anything]]) + sig { void } def index render json: Swagger::Blocks.build_root_json(SWAGGERED_CLASSES) end diff --git a/app/controllers/api/v8/application/credentials_controller.rb b/app/controllers/api/v8/application/credentials_controller.rb index e8058673d..c1676c9dc 100644 --- a/app/controllers/api/v8/application/credentials_controller.rb +++ b/app/controllers/api/v8/application/credentials_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api @@ -5,9 +6,10 @@ module V8 module Application class CredentialsController < Api::V8::BaseController skip_authorization_check + sig { void } def index valid_clients = SiteSetting.value('valid_clients') - .map { |o| o['name'] } + .pluck('name') return unless valid_clients.is_a?(Enumerable) return render json: 'Client not supported!', status: :forbidden unless valid_clients.include?(params[:application_name]) app = Doorkeeper::Application.create_with(redirect_uri: 'urn:ietf:wg:oauth:2.0:oob').find_or_create_by!(name: params[:application_name]) diff --git a/app/controllers/api/v8/base_controller.rb b/app/controllers/api/v8/base_controller.rb index c9b362808..19c3ab19b 100644 --- a/app/controllers/api/v8/base_controller.rb +++ b/app/controllers/api/v8/base_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require 'json' @@ -6,6 +7,7 @@ module Api module V8 class BaseController < ApplicationController + extend T::Sig clear_respond_to respond_to :json @@ -15,6 +17,7 @@ class BaseController < ApplicationController skip_before_action :verify_authenticity_token rescue_from CanCan::AccessDenied do |e| + T.bind(self, Api::V8::BaseController) if current_user.guest? respond_unauthorized(e.message) else @@ -23,14 +26,17 @@ class BaseController < ApplicationController end rescue_from CoursesMoocFiTokenIntrospector::Unavailable do |e| + T.bind(self, Api::V8::BaseController) Rails.logger.error("courses.mooc.fi token introspection unavailable: #{e.message}") respond_with_error('courses.mooc.fi could not verify your login right now. Try again later.', 503) end rescue_from ActiveRecord::RecordNotFound do |e| + T.bind(self, Api::V8::BaseController) render json: errors_json(e.message), status: :not_found end + sig { params(hash: T.untyped).void } def present(hash) if params[:pretty] render json: JSON.pretty_generate(hash) @@ -40,41 +46,52 @@ def present(hash) end private + sig { void } def authenticate_user! - return @current_user if @current_user + return if @current_user if doorkeeper_token - @current_user ||= User.find_by(id: doorkeeper_token.resource_owner_id) + @current_user = T.let(User.find_by(id: doorkeeper_token.resource_owner_id), T.nilable(User)) raise 'Invalid token' unless @current_user elsif Rails.configuration.x.accept_courses_mooc_fi_tokens @current_user = CoursesMoocFiAuthentication.user_for(request) - @auth_source = :courses_mooc_fi_token if @current_user + @auth_source = T.let(:courses_mooc_fi_token, T.nilable(Symbol)) if @current_user end @current_user ||= user_from_session || Guest.new end - attr_reader :current_user + # Before authenticate_user! has run (ApplicationController's own before_actions), this is a Guest. + sig { returns(User) } + def current_user + @current_user || Guest.new + end + sig { returns(::Ability) } def current_ability - @current_ability ||= ::Ability.new(current_user, auth_source: @auth_source) + @current_ability ||= T.let(::Ability.new(current_user, auth_source: @auth_source), T.nilable(::Ability)) end + sig { params(messages: T.any(String, T::Array[String])).returns(T::Hash[Symbol, T::Array[String]]) } def errors_json(messages) { errors: [*messages] } end + sig { params(msg: String).void } def respond_not_found(msg = 'Not Found') respond_with_error(msg, 404) end + sig { params(msg: String).void } def respond_forbidden(msg = 'Forbidden') respond_with_error(msg, 403) end + sig { params(msg: String).void } def respond_unauthorized(msg = 'Authentication required') respond_with_error(msg, 401) end - def respond_with_error(msg, code = 500, exception = nil, extra_json_keys = {}) + sig { params(msg: String, code: Integer, _exception: T.nilable(Exception), extra_json_keys: T::Hash[Symbol, T.untyped]).void } + def respond_with_error(msg, code = 500, _exception = nil, extra_json_keys = {}) respond_to do |format| format.html do render json: errors_json(msg), status: code @@ -87,6 +104,7 @@ def respond_with_error(msg, code = 500, exception = nil, extra_json_keys = {}) end end + sig { void } def check_client_version_api_v8 if should_check_for_client_version? begin @@ -110,6 +128,7 @@ def check_client_version_api_v8 end end + sig { returns(T::Boolean) } def should_check_for_client_version? params[:format] == 'json' && (params[:client].present? && params[:client_version].present?) && @@ -117,6 +136,7 @@ def should_check_for_client_version? (controller_name == 'submissions' && action_name == 'create') end + sig { params(client_name: T.untyped, client_version: T.untyped).void } def check_client_minimum_version(client_name, client_version) begin client_version = Version.new(client_version) unless client_version.nil? diff --git a/app/controllers/api/v8/core.rb b/app/controllers/api/v8/core.rb index ca589c3b8..4803504c9 100644 --- a/app/controllers/api/v8/core.rb +++ b/app/controllers/api/v8/core.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api diff --git a/app/controllers/api/v8/core/courses/reviews_controller.rb b/app/controllers/api/v8/core/courses/reviews_controller.rb index 04e34df6a..2eec1b4ce 100644 --- a/app/controllers/api/v8/core/courses/reviews_controller.rb +++ b/app/controllers/api/v8/core/courses/reviews_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require 'natsort' @@ -7,6 +8,7 @@ module V8 module Core module Courses class ReviewsController < Api::V8::BaseController + extend T::Sig include Swagger::Blocks swagger_path '/api/v8/core/courses/{course_id}/reviews' do @@ -53,6 +55,7 @@ class ReviewsController < Api::V8::BaseController end end + sig { void } def index unauthorize_guest! course = Course.find_by!(id: params[:course_id]) @@ -60,14 +63,15 @@ def index users_reviewed_submissions = course.submissions .where(user_id: current_user.id) .where('requests_review OR requires_review OR reviewed') - .order('created_at DESC') + .order(created_at: :desc) present Review.course_reviews_json(course, users_reviewed_submissions, view_context) end + sig { void } def update - fetch :review - authorize! :update, @review + review = fetch_review + authorize! :update, review if params[:review].present? && params[:review].has_key?(:review_body) update_review elsif params[:mark_as_read] @@ -77,69 +81,63 @@ def update end end + sig { void } def update_review - fetch :review - authorize! :update, @review - @review.review_body = params[:review][:review_body] + review = fetch_review + authorize! :update, review + review.review_body = params[:review][:review_body] begin - mark_as_reviewed - award_points - @review.submission.save! - @review.save! + mark_as_reviewed(review) + award_points(review) + T.must(review.submission).save! + review.save! rescue StandardError ::Rails.logger.error($!) respond_with_error('Failed to save code review.') else - present(status: 200) && return + present(status: 200) end end + sig { params(read: T::Boolean).void } def mark_as_read(read) - fetch :review - authorize!(read ? :mark_as_read : :mark_as_unread, @review) + review = fetch_review + authorize!(read ? :mark_as_read : :mark_as_unread, review) - @review.marked_as_read = read - present(status: 200) && return if @review.save + review.marked_as_read = read + present(status: 200) if review.save end - def fetch(*stuff) - if stuff.include? :course - @course = Course.find(params[:course_id]) - authorize! :read, @course - end - if stuff.include? :submission - @submission = Submission.find(params[:submission_id]) - authorize! :read, @submission - end - if stuff.include? :review - @review = Review.find(params[:id]) - authorize! :read, @review - end - return unless stuff.include? :files - @files = SourceFileList.for_submission(@submission) + sig { returns(Review) } + def fetch_review + review = Review.find(params[:id]) + authorize! :read, review + review end - def mark_as_reviewed - sub = @review.submission + sig { params(review: Review).void } + def mark_as_reviewed(review) + sub = T.must(review.submission) sub.reviewed = true sub.review_dismissed = false sub.of_same_kind .where('(requires_review OR requests_review) AND NOT reviewed') - .where(['created_at < ?', sub.created_at]) + .where(created_at: ...sub.created_at) .update_all(newer_submission_reviewed: true) end - def award_points - submission = @review.submission + sig { params(review: Review).void } + def award_points(review) + submission = T.must(review.submission) exercise = submission.exercise - course = exercise.course raise 'Exercise of submission has been moved or deleted' unless exercise + course = T.must(exercise.course) available_points = exercise.available_points.where(requires_review: true).map(&:name) previous_points = course.awarded_points.where(user_id: submission.user_id, name: available_points).map(&:name) - new_points = [] + new_points = T.let([], T::Array[String]) if params[:review][:points].respond_to?(:keys) params[:review][:points].keys.each do |point_name| unless exercise.available_points.where(name: point_name).any? @@ -157,7 +155,7 @@ def award_points end end - @review.points = (@review.points_list + new_points + previous_points).uniq.natsort.join(' ') + review.points = (review.points_list + new_points + previous_points).uniq.natsort.join(' ') submission.points = (submission.points_list + new_points + previous_points).uniq.natsort.join(' ') end end diff --git a/app/controllers/api/v8/core/courses/unlocks_controller.rb b/app/controllers/api/v8/core/courses/unlocks_controller.rb index 4ed46d0be..56286f51c 100644 --- a/app/controllers/api/v8/core/courses/unlocks_controller.rb +++ b/app/controllers/api/v8/core/courses/unlocks_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api @@ -29,6 +30,7 @@ class UnlocksController < Api::V8::BaseController around_action :wrap_transaction + sig { void } def create unauthorize_guest! course = Course.find(params[:course_id]) diff --git a/app/controllers/api/v8/core/courses_controller.rb b/app/controllers/api/v8/core/courses_controller.rb index c494875ec..ab481874a 100644 --- a/app/controllers/api/v8/core/courses_controller.rb +++ b/app/controllers/api/v8/core/courses_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require 'course_info' @@ -32,6 +33,7 @@ class CoursesController < Api::V8::BaseController end end + sig { void } def show unauthorize_guest! course = Course.find_by!(id: params[:id]) diff --git a/app/controllers/api/v8/core/exercises/details_controller.rb b/app/controllers/api/v8/core/exercises/details_controller.rb index 784edfa4d..1eface8c7 100644 --- a/app/controllers/api/v8/core/exercises/details_controller.rb +++ b/app/controllers/api/v8/core/exercises/details_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api @@ -43,9 +44,10 @@ class DetailsController < Api::V8::BaseController skip_authorization_check + sig { void } def show exercise_ids = params[:ids] - return respond_not_found('Query param ids is empty. Example: ?ids=1,2,3') if !exercise_ids.present? || exercise_ids.empty? + return respond_not_found('Query param ids is empty. Example: ?ids=1,2,3') if exercise_ids.blank? || exercise_ids.empty? exercises = Exercise.where(id: exercise_ids.split(',')).includes(:course) authorize! :read, exercises @@ -53,9 +55,9 @@ def show { id: exercise.id, checksum: exercise.checksum, - course_name: exercise.course.name, + course_name: T.must(exercise.course).name, exercise_name: exercise.name, - hide_submission_results: (exercise.course.hide_submission_results || exercise.hide_submission_results), + hide_submission_results: (T.must(exercise.course).hide_submission_results || exercise.hide_submission_results), } end present exercises: data diff --git a/app/controllers/api/v8/core/exercises/solutions_controller.rb b/app/controllers/api/v8/core/exercises/solutions_controller.rb index 639fb1399..6736bd297 100644 --- a/app/controllers/api/v8/core/exercises/solutions_controller.rb +++ b/app/controllers/api/v8/core/exercises/solutions_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api @@ -27,6 +28,7 @@ class SolutionsController < Api::V8::BaseController around_action :wrap_transaction + sig { void } def download unauthorize_guest! exercise = Exercise.find_by!(id: params[:exercise_id]) diff --git a/app/controllers/api/v8/core/exercises/submissions_controller.rb b/app/controllers/api/v8/core/exercises/submissions_controller.rb index 8ff23ccff..c71273f58 100644 --- a/app/controllers/api/v8/core/exercises/submissions_controller.rb +++ b/app/controllers/api/v8/core/exercises/submissions_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require 'submission_processor' @@ -36,26 +37,27 @@ class SubmissionsController < Api::V8::BaseController around_action :wrap_transaction + sig { void } def create unauthorize_guest! - @exercise = Exercise.find(params[:exercise_id]) - authorize! :read, @exercise - @course = @exercise.course - authorize! :read, @course + exercise = Exercise.find(params[:exercise_id]) + authorize! :read, exercise + course = T.must(exercise.course) + authorize! :read, course if !params[:submission] || !params[:submission][:file] authorization_skip! return respond_not_found('No ZIP file selected or failed to receive it') end - unless @exercise.submittable_by?(current_user) + unless exercise.submittable_by?(current_user) authorization_skip! return respond_forbidden('Submissions for this exercise are no longer accepted.') end # Check if user has too many processing submissions for this exercise - processing_count = current_user.processing_submissions_count_for_exercise(@exercise.name, @course.id) + processing_count = current_user.processing_submissions_count_for_exercise(T.must(exercise.name), course.id) if processing_count >= 3 authorization_skip! return respond_forbidden("Too many submissions currently processing for this exercise. You have #{processing_count} submissions being processed. Please wait for them to complete before submitting again. All your submissions will eventually get processed and you will get your points.") @@ -76,10 +78,10 @@ def create low_priority = Submission.where(created_at: (Time.now - 10.minutes)..Time.now, user: current_user).count > 3 unless errormsg - @submission = Submission.new( + submission = Submission.new( user: current_user, - course: @course, - exercise: @exercise, + course: course, + exercise: exercise, return_file: file_contents, params_json: submission_params.to_json, requests_review: !params[:request_review].nil?, @@ -102,22 +104,22 @@ def create processing_priority: low_priority ? -100 : 0 ) - authorize! :create, @submission + authorize! :create, submission - unless @submission.save + unless submission.save errormsg = 'Failed to save submission.' - errormsg += " Errors: #{@submission.errors.messages}" + errormsg += " Errors: #{submission.errors.messages}" end end unless errormsg - # SubmissionProcessor.new.process_submission(@submission) + # SubmissionProcessor.new.process_submission(submission) end if !errormsg - render json: { submission_url: api_v8_core_submission_url(@submission), - paste_url: @submission.paste_key ? paste_url(@submission.paste_key) : '', - show_submission_url: submission_url(@submission) } + render json: { submission_url: api_v8_core_submission_url(submission), + paste_url: submission.paste_key ? paste_url(submission.paste_key) : '', + show_submission_url: submission_url(submission) } else render json: { error: errormsg } end diff --git a/app/controllers/api/v8/core/exercises_controller.rb b/app/controllers/api/v8/core/exercises_controller.rb index dcc30ab66..152f60eb2 100644 --- a/app/controllers/api/v8/core/exercises_controller.rb +++ b/app/controllers/api/v8/core/exercises_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require 'submission_list' @@ -49,6 +50,7 @@ class ExercisesController < Api::V8::BaseController end end + sig { void } def download exercise = Exercise.find(params[:id]) @@ -56,10 +58,11 @@ def download send_file exercise.stub_zip_file_path end + sig { void } def show unauthorize_guest! exercise = Exercise.find(params[:id]) - course = Course.find(exercise.course_id) + course = Course.find(T.must(exercise.course_id)) authorize! :read, course authorize! :read, exercise diff --git a/app/controllers/api/v8/core/organizations/courses_controller.rb b/app/controllers/api/v8/core/organizations/courses_controller.rb index 0f636f118..3cdaa4a83 100644 --- a/app/controllers/api/v8/core/organizations/courses_controller.rb +++ b/app/controllers/api/v8/core/organizations/courses_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api @@ -27,6 +28,7 @@ class CoursesController < Api::V8::BaseController end end + sig { void } def index unauthorize_guest! organization = Organization.find_by!(slug: params[:organization_slug]) @@ -34,7 +36,7 @@ def index courses = organization.courses.ongoing.order(ordering) courses = courses.select { |c| c.visible_to?(current_user) } authorize! :read, courses - present courses.map { |c| c.links_as_json(view_context) } + present(courses.map { |c| c.links_as_json(view_context) }) end end end diff --git a/app/controllers/api/v8/core/submissions/feedback_controller.rb b/app/controllers/api/v8/core/submissions/feedback_controller.rb index 4cdadae6f..384cabd08 100644 --- a/app/controllers/api/v8/core/submissions/feedback_controller.rb +++ b/app/controllers/api/v8/core/submissions/feedback_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api @@ -38,6 +39,7 @@ class FeedbackController < Api::V8::BaseController end end + sig { void } def create submission = Submission.find(params[:submission_id]) authorize! :read, submission diff --git a/app/controllers/api/v8/core/submissions/reviews_controller.rb b/app/controllers/api/v8/core/submissions/reviews_controller.rb index 8ccf4ab9f..d85800525 100644 --- a/app/controllers/api/v8/core/submissions/reviews_controller.rb +++ b/app/controllers/api/v8/core/submissions/reviews_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require 'natsort' @@ -7,6 +8,7 @@ module V8 module Core module Submissions class ReviewsController < Api::V8::BaseController + extend T::Sig include Swagger::Blocks swagger_path '/api/v8/core/submissions/{submission_id}/reviews' do @@ -33,12 +35,13 @@ class ReviewsController < Api::V8::BaseController around_action :wrap_transaction + sig { void } def create unauthorize_guest! submission = Submission.find(params[:submission_id]) authorize! :read, submission - @review = Review.new( + review = Review.new( submission_id: submission.id, reviewer_id: current_user.id, review_body: params[:review][:review_body] @@ -48,31 +51,32 @@ def create begin ActiveRecord::Base.connection.transaction do - award_points - mark_as_reviewed - @review.submission.save! - @review.save! + award_points(review) + mark_as_reviewed(review) + T.must(review.submission).save! + review.save! end rescue StandardError ::Rails.logger.error($!) respond_with_error('Failed to save code review.') else - send_email_about_new_review if params[:send_email] + send_email_about_new_review(review) if params[:send_email] present(status: 'ok') end end private - def award_points - submission = @review.submission + sig { params(review: Review).void } + def award_points(review) + submission = T.must(review.submission) exercise = submission.exercise - course = exercise.course raise 'Exercise of submission has been moved or deleted' unless exercise + course = T.must(exercise.course) available_points = exercise.available_points.where(requires_review: true).map(&:name) previous_points = course.awarded_points.where(user_id: submission.user_id, name: available_points).map(&:name) - new_points = [] + new_points = T.let([], T::Array[String]) if params[:review][:points].respond_to?(:keys) params[:review][:points].keys.each do |point_name| unless exercise.available_points.where(name: point_name).any? @@ -90,22 +94,24 @@ def award_points end end - @review.points = (@review.points_list + new_points + previous_points).uniq.natsort.join(' ') + review.points = (review.points_list + new_points + previous_points).uniq.natsort.join(' ') submission.points = (submission.points_list + new_points + previous_points).uniq.natsort.join(' ') end - def mark_as_reviewed - sub = @review.submission + sig { params(review: Review).void } + def mark_as_reviewed(review) + sub = T.must(review.submission) sub.reviewed = true sub.review_dismissed = false sub.of_same_kind .where('(requires_review OR requests_review) AND NOT reviewed') - .where(['created_at < ?', sub.created_at]) + .where(created_at: ...sub.created_at) .update_all(newer_submission_reviewed: true) end - def send_email_about_new_review - ReviewMailer.review_email(@review).deliver + sig { params(review: Review).void } + def send_email_about_new_review(review) + ReviewMailer.review_email(review).deliver_now end end end diff --git a/app/controllers/api/v8/core/submissions_controller.rb b/app/controllers/api/v8/core/submissions_controller.rb index ad3509d87..6d03da2bc 100644 --- a/app/controllers/api/v8/core/submissions_controller.rb +++ b/app/controllers/api/v8/core/submissions_controller.rb @@ -1,9 +1,11 @@ +# typed: strict # frozen_string_literal: true module Api module V8 module Core class SubmissionsController < Api::V8::BaseController + extend T::Sig include Swagger::Blocks swagger_path '/api/v8/core/submissions/{submission_id}/download' do @@ -26,73 +28,74 @@ class SubmissionsController < Api::V8::BaseController around_action :wrap_transaction + sig { void } def download unauthorize_guest! submission = Submission.find(params[:id]) authorize! :download, submission - send_data(submission.return_file, filename: "#{submission.user.login}-#{submission.exercise.name}-#{submission.id}.zip") + send_data(submission.return_file, filename: "#{T.must(submission.user).login}-#{submission.exercise_name}-#{submission.id}.zip") end + sig { void } def show - @submission = Submission.find_by!(id: params[:id]) - unless @submission.processed? + submission = Submission.find_by!(id: params[:id]) + unless submission.processed? authorization_skip! sandbox_status = :created - if @submission.processing_began_at + if submission.processing_began_at sandbox_status = :processing_on_sandbox - elsif @submission.processing_tried_at + elsif submission.processing_tried_at sandbox_status = :sending_to_sandbox end status = :processing - status = :hidden unless @submission.can_see_results?(current_user) + status = :hidden unless submission.can_see_results?(current_user) return render json: { status: status, sandbox_status: sandbox_status } end unauthorize_guest! - authorize! :read, @submission + authorize! :read, submission # This gets invalidated for the submitter when the results arrive so that they see the results right away. - output = Rails.cache.fetch("api_v8_core_submission_show_#{@submission.id}_user_#{current_user.id}", expires_in: 30.seconds) do - @course = @submission.course - @exercise = @submission.exercise - @organization = @course.organization - output = { + output = Rails.cache.fetch("api_v8_core_submission_show_#{submission.id}_user_#{current_user.id}", expires_in: 30.seconds) do + course = T.must(submission.course) + exercise = submission.exercise + submission_json = { api_version: ApiVersion::API_VERSION, - all_tests_passed: @submission.all_tests_passed?, - user_id: @submission.user_id, - login: @submission.user.login, - course: @course.name, - exercise_name: @submission.exercise.name, - status: @submission.status(current_user), - points: @submission.points_list, - validations: @submission.validations, - valgrind: @submission.valgrind, - submission_url: submission_url(@submission), - solution_url: @exercise.solution.visible_to?(current_user, true) ? view_context.exercise_solution_url(@exercise) : nil, - submitted_at: @submission.created_at, - processing_time: @submission.processing_time, - reviewed: @submission.reviewed?, - requests_review: @submission.requests_review?, - paste_url: @submission.paste_available ? paste_url(@submission.paste_key) : nil, - message_for_paste: @submission.paste_available ? @submission.message_for_paste : nil, - missing_review_points: @exercise.missing_review_points_for(@submission.user) + all_tests_passed: submission.all_tests_passed?, + user_id: submission.user_id, + login: T.must(submission.user).login, + course: course.name, + exercise_name: submission.exercise_name, + status: submission.status(current_user), + points: submission.points_list, + validations: submission.validations, + valgrind: submission.valgrind, + submission_url: submission_url(submission), + solution_url: exercise && exercise.solution.visible_to?(current_user, true) ? view_context.exercise_solution_url(exercise) : nil, + submitted_at: submission.created_at, + processing_time: submission.processing_time, + reviewed: submission.reviewed?, + requests_review: submission.requests_review?, + paste_url: submission.paste_available ? paste_url(submission.paste_key) : nil, + message_for_paste: submission.paste_available ? submission.message_for_paste : nil, + missing_review_points: exercise ? exercise.missing_review_points_for(T.must(submission.user)) : [] } - output = output.merge( - case @submission.status(current_user) + status_fields = + case submission.status(current_user) when :processing then { - submissions_before_this: @submission.unprocessed_submissions_before_this, + submissions_before_this: submission.unprocessed_submissions_before_this, total_unprocessed: Submission.unprocessed_count } when :ok then { - test_cases: @submission.test_case_records, - feedback_questions: @course.feedback_questions.order(:position).map(&:record_for_api), - feedback_answer_url: api_v8_core_submission_feedback_index_url(@submission, format: :json) + test_cases: submission.test_case_records, + feedback_questions: course.feedback_questions.order(:position).map(&:record_for_api), + feedback_answer_url: api_v8_core_submission_feedback_index_url(submission, format: :json) } when :fail then { - test_cases: @submission.test_case_records + test_cases: submission.test_case_records } when :hidden then { all_tests_passed: nil, @@ -102,32 +105,19 @@ def show valgrind: nil } when :error then { - error: @submission.pretest_error + error: submission.pretest_error } end - ) - output[:status] = :ok if output[:status] == :hidden && !can_handle_hidden_fields(params) + submission_json = submission_json.merge(T.must(status_fields)) + submission_json[:status] = :ok if submission_json[:status] == :hidden && !netbeans_plugin_at_least?('1.2.5') if !!params[:include_files] - output[:files] = SourceFileList.for_submission(@submission).map { |f| { path: f.path, contents: f.contents } } + submission_json[:files] = SourceFileList.for_submission(submission).map { |f| { path: f.path, contents: f.contents } } end - output + submission_json end render json: output end - - private - def can_handle_hidden_fields(params) - return false unless params[:client] == 'netbeans_plugin' - splitted = params[:client_version].split('.') - min_version = [1, 2, 5] - min_version.each_with_index do |version_number, i| - return false unless splitted[i] - return false if splitted[i].to_i < version_number - return true if splitted[i].to_i > version_number - end - true - end end end end diff --git a/app/controllers/api/v8/courses/all_courses_with_this_template_controller.rb b/app/controllers/api/v8/courses/all_courses_with_this_template_controller.rb index b481be2c6..445622c03 100644 --- a/app/controllers/api/v8/courses/all_courses_with_this_template_controller.rb +++ b/app/controllers/api/v8/courses/all_courses_with_this_template_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api @@ -35,6 +36,7 @@ class AllCoursesWithThisTemplateController < Api::V8::BaseController end end + sig { void } def index unauthorize_guest! course = Course.find_by!(id: params[:course_id]) diff --git a/app/controllers/api/v8/courses/exercises/points_controller.rb b/app/controllers/api/v8/courses/exercises/points_controller.rb index a6ecb8cc8..e55115399 100644 --- a/app/controllers/api/v8/courses/exercises/points_controller.rb +++ b/app/controllers/api/v8/courses/exercises/points_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api @@ -33,6 +34,7 @@ class PointsController < Api::V8::BaseController end end + sig { void } def index unauthorize_guest! course = Course.find_by!(id: params[:course_id]) @@ -42,7 +44,7 @@ def index course_id: course.id) authorize! :read, points - present(points.as_json_with_exercise_ids(course.exercises)) + present(AwardedPoint.as_json_with_exercise_ids(points, course.exercises)) end end end diff --git a/app/controllers/api/v8/courses/exercises/users/points_controller.rb b/app/controllers/api/v8/courses/exercises/users/points_controller.rb index eabed9ffd..320cb16dc 100644 --- a/app/controllers/api/v8/courses/exercises/users/points_controller.rb +++ b/app/controllers/api/v8/courses/exercises/users/points_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api @@ -61,6 +62,7 @@ class PointsController < Api::V8::BaseController end end + sig { void } def index unauthorize_guest! course = Course.find_by!(id: params[:course_id]) @@ -72,7 +74,7 @@ def index user_id: params[:user_id]) authorize! :read, points - present(points.as_json_with_exercise_ids(course.exercises)) + present(AwardedPoint.as_json_with_exercise_ids(points, course.exercises)) end end end diff --git a/app/controllers/api/v8/courses/exercises_controller.rb b/app/controllers/api/v8/courses/exercises_controller.rb index 0523b3399..1583218fb 100644 --- a/app/controllers/api/v8/courses/exercises_controller.rb +++ b/app/controllers/api/v8/courses/exercises_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api @@ -31,9 +32,10 @@ class ExercisesController < Api::V8::BaseController end end + sig { void } def index unauthorize_guest! - course = Course.find_by!(id: params[:course_id]) if params[:course_id] + course = Course.find_by!(id: params[:course_id]) can_see_everything = current_user.administrator? || current_user.teacher?(course.organization) || current_user.assistant?(course) authorize! :read, course @@ -59,7 +61,7 @@ def index points_visible = ex.points_visible_to?(current_user) available_points = ex.available_points available_points_names = available_points.map(&:name) - awarded_points = all_awarded_points.select { |awp| available_points_names.include?(awp.name) }.map(&:name) + awarded_points = all_awarded_points.filter_map { |awp| awp.name if available_points_names.include?(awp.name) } { id: ex.id, available_points: points_visible ? available_points : [], diff --git a/app/controllers/api/v8/courses/points_controller.rb b/app/controllers/api/v8/courses/points_controller.rb index a099a997c..15322d568 100644 --- a/app/controllers/api/v8/courses/points_controller.rb +++ b/app/controllers/api/v8/courses/points_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api @@ -26,11 +27,12 @@ class PointsController < Api::V8::BaseController end end + sig { void } def index course = Course.find_by!(id: params[:course_id]) points = course.awarded_points.includes(:submission) authorize_collection :read, points - present points.as_json_with_exercise_ids(course.exercises) + present AwardedPoint.as_json_with_exercise_ids(points, course.exercises) end end end diff --git a/app/controllers/api/v8/courses/submissions/last_hour_controller.rb b/app/controllers/api/v8/courses/submissions/last_hour_controller.rb index 4df2fd339..94924ed72 100644 --- a/app/controllers/api/v8/courses/submissions/last_hour_controller.rb +++ b/app/controllers/api/v8/courses/submissions/last_hour_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api @@ -29,6 +30,7 @@ class LastHourController < Api::V8::BaseController skip_authorization_check + sig { void } def index return respond_forbidden unless current_user.administrator? course = Course.find(params[:course_id]) diff --git a/app/controllers/api/v8/courses/submissions_controller.rb b/app/controllers/api/v8/courses/submissions_controller.rb index 680b8ef53..edb9ff043 100644 --- a/app/controllers/api/v8/courses/submissions_controller.rb +++ b/app/controllers/api/v8/courses/submissions_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api @@ -37,6 +38,7 @@ class SubmissionsController < Api::V8::BaseController around_action :wrap_transaction + sig { void } def index unauthorize_guest! course = Course.find_by!(id: params[:course_id]) diff --git a/app/controllers/api/v8/courses/users/points_controller.rb b/app/controllers/api/v8/courses/users/points_controller.rb index 26d27aeaa..e00f815fb 100644 --- a/app/controllers/api/v8/courses/users/points_controller.rb +++ b/app/controllers/api/v8/courses/users/points_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api @@ -48,12 +49,13 @@ class PointsController < Api::V8::BaseController end end + sig { void } def index course = Course.find_by!(id: params[:course_id]) params[:user_id] = current_user.id if params[:user_id] == 'current' points = course.awarded_points.includes(:submission).where(user_id: params[:user_id]) authorize_collection :read, points - present points.as_json_with_exercise_ids(course.exercises) + present AwardedPoint.as_json_with_exercise_ids(points, course.exercises) end end end diff --git a/app/controllers/api/v8/courses/users/submissions_controller.rb b/app/controllers/api/v8/courses/users/submissions_controller.rb index cd4e88a39..c4b6c6258 100644 --- a/app/controllers/api/v8/courses/users/submissions_controller.rb +++ b/app/controllers/api/v8/courses/users/submissions_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api @@ -68,6 +69,7 @@ class SubmissionsController < Api::V8::BaseController around_action :wrap_transaction + sig { void } def index unauthorize_guest! course = Course.find_by!(id: params[:course_id]) diff --git a/app/controllers/api/v8/courses_controller.rb b/app/controllers/api/v8/courses_controller.rb index c51c5979c..156974455 100644 --- a/app/controllers/api/v8/courses_controller.rb +++ b/app/controllers/api/v8/courses_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api @@ -24,6 +25,7 @@ class CoursesController < Api::V8::BaseController end end + sig { void } def show unauthorize_guest! course = Course.find_by!(id: params[:id]) diff --git a/app/controllers/api/v8/exercises/model_solutions_controller.rb b/app/controllers/api/v8/exercises/model_solutions_controller.rb index 63af57422..cb7c8e024 100644 --- a/app/controllers/api/v8/exercises/model_solutions_controller.rb +++ b/app/controllers/api/v8/exercises/model_solutions_controller.rb @@ -1,13 +1,15 @@ +# typed: strict # frozen_string_literal: true module Api module V8 module Exercises class ModelSolutionsController < Api::V8::BaseController + sig { void } def index unauthorize_guest! exercise = Exercise.find(params[:exercise_id]) - course = exercise.course + course = T.must(exercise.course) authorize! :read, exercise solution = exercise.solution begin diff --git a/app/controllers/api/v8/exercises/users/submissions_controller.rb b/app/controllers/api/v8/exercises/users/submissions_controller.rb index 391926c2a..3672e9b76 100644 --- a/app/controllers/api/v8/exercises/users/submissions_controller.rb +++ b/app/controllers/api/v8/exercises/users/submissions_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api @@ -58,6 +59,7 @@ class SubmissionsController < Api::V8::BaseController end end + sig { void } def index unauthorize_guest! exercise = Exercise.find_by!(id: params[:exercise_id]) diff --git a/app/controllers/api/v8/organizations/courses/exercises/points_controller.rb b/app/controllers/api/v8/organizations/courses/exercises/points_controller.rb index d01221218..d16b666f5 100644 --- a/app/controllers/api/v8/organizations/courses/exercises/points_controller.rb +++ b/app/controllers/api/v8/organizations/courses/exercises/points_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api @@ -35,6 +36,7 @@ class PointsController < Api::V8::BaseController end end + sig { void } def index unauthorize_guest! course = Course.find_by!(name: "#{params[:organization_slug]}-#{params[:course_name]}") @@ -44,7 +46,7 @@ def index course_id: course.id) authorize! :read, points - present(points.as_json_with_exercise_ids(course.exercises)) + present(AwardedPoint.as_json_with_exercise_ids(points, course.exercises)) end end end diff --git a/app/controllers/api/v8/organizations/courses/exercises/users/points_controller.rb b/app/controllers/api/v8/organizations/courses/exercises/users/points_controller.rb index 1b48b88a8..5b256c3d8 100644 --- a/app/controllers/api/v8/organizations/courses/exercises/users/points_controller.rb +++ b/app/controllers/api/v8/organizations/courses/exercises/users/points_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api @@ -64,6 +65,7 @@ class PointsController < Api::V8::BaseController end end + sig { void } def index unauthorize_guest! course = Course.find_by!(name: "#{params[:organization_slug]}-#{params[:course_name]}") @@ -75,7 +77,7 @@ def index user_id: params[:user_id]) authorize! :read, points - present(points.as_json_with_exercise_ids(course.exercises)) + present(AwardedPoint.as_json_with_exercise_ids(points, course.exercises)) end end end diff --git a/app/controllers/api/v8/organizations/courses/exercises_controller.rb b/app/controllers/api/v8/organizations/courses/exercises_controller.rb index ec8cff354..0b3f2728d 100644 --- a/app/controllers/api/v8/organizations/courses/exercises_controller.rb +++ b/app/controllers/api/v8/organizations/courses/exercises_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api @@ -52,6 +53,7 @@ class ExercisesController < Api::V8::BaseController end end + sig { void } def index unauthorize_guest! course = Course.find_by!(name: "#{params[:organization_slug]}-#{params[:course_name]}") @@ -74,6 +76,7 @@ def index present(presentable) end + sig { void } def show authorization_skip! organization = Organization.find_by!(slug: params[:organization_slug]) @@ -106,7 +109,7 @@ def show end if grant_model_solution_token_every_nth_completed_exercise && grant_model_solution_token_every_nth_completed_exercise > 0 completed_exercises_count = course.submissions.where(all_tests_passed: true, user: current_user).distinct.select(:exercise_name).count - total_model_solution_tokens = completed_exercises_count / grant_model_solution_token_every_nth_completed_exercise + (course.initial_coin_stash || 0) + total_model_solution_tokens = (completed_exercises_count / grant_model_solution_token_every_nth_completed_exercise) + (course.initial_coin_stash || 0) tokens_used = if course.large_exercises_consume_more_coins? ModelSolutionTokenUsed.where(user: current_user, course: course).sum(:cost) @@ -140,6 +143,7 @@ def show ) end + sig { void } def download course = Course.find_by!(name: "#{params[:organization_slug]}-#{params[:course_name]}") exercise = Exercise.find_by!(name: params[:name], course_id: course.id) diff --git a/app/controllers/api/v8/organizations/courses/points_controller.rb b/app/controllers/api/v8/organizations/courses/points_controller.rb index 2155bae10..c06be0b7e 100644 --- a/app/controllers/api/v8/organizations/courses/points_controller.rb +++ b/app/controllers/api/v8/organizations/courses/points_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api @@ -28,11 +29,12 @@ class PointsController < Api::V8::BaseController end end + sig { void } def index course = Course.find_by!(name: "#{params[:organization_slug]}-#{params[:course_name]}") points = course.awarded_points.includes(:submission) authorize_collection :read, points - present points.as_json_with_exercise_ids(course.exercises) + present AwardedPoint.as_json_with_exercise_ids(points, course.exercises) end end end diff --git a/app/controllers/api/v8/organizations/courses/studyright_eligibility_controller.rb b/app/controllers/api/v8/organizations/courses/studyright_eligibility_controller.rb index 818b761ba..1aed52813 100644 --- a/app/controllers/api/v8/organizations/courses/studyright_eligibility_controller.rb +++ b/app/controllers/api/v8/organizations/courses/studyright_eligibility_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api @@ -30,6 +31,7 @@ class StudyrightEligibilityController < Api::V8::BaseController skip_authorization_check + sig { void } def eligible_students unauthorize_guest! @@ -41,11 +43,12 @@ def eligible_students applied_students = UserAppDatum.where(field_name: 'applies_for_study_right', value: 't', namespace: 'ohjelmoinnin-mooc-2019').pluck(:user_id) - groups = course.exercise_groups[0..6] + course.exercise_groups[7..13] - - cbu = course.exercise_group_completion_by_user - - user_ids = groups.flat_map { |group| ap = cbu[group.name][:available_points]; cbu[group.name][:points_by_user].map { |k, v| { k => (v.to_f / ap) } } }.group_by { |o| o.keys.first }.map { |k, v| { k => v.map { |o2| o2[k] } } }.inject(:merge).select { |_k, v| v.length == groups.length }.select { |_k, v| v.all? { |o2| o2 >= 0.8995 } }.map { |k, _v| k } + completion_by_group = course.exercise_group_completion_by_user + # Groups without available points are absent from the completion data and can't be failed. + completions = course.exercise_groups.first(14).filter_map { |group| completion_by_group[group.name] } + user_ids = completions.map do |completion| + completion[:points_by_user].select { |_user_id, points| points.to_f / completion[:available_points] >= 0.8995 }.keys + end.reduce(:&) || [] eligble_ids = (user_ids & applied_students) users = User.where(id: eligble_ids) @@ -66,10 +69,10 @@ def eligible_students } if user_id_to_extra_fields extra_fields = user_id_to_extra_fields[u.id] || [] - d[:extra_fields] = extra_fields.map { |o| [o.field_name, o.value] }.to_h + d[:extra_fields] = extra_fields.to_h { |o| [o.field_name, o.value] } end if params[:user_fields] - user_fields = u.user_field_values.map { |o| [o.field_name, o.value] }.to_h + user_fields = u.user_field_values.to_h { |o| [o.field_name, o.value] } d[:user_fields] = user_fields d[:student_number] = user_fields['organizational_id'] d[:first_name] = user_fields['first_name'] diff --git a/app/controllers/api/v8/organizations/courses/submissions_controller.rb b/app/controllers/api/v8/organizations/courses/submissions_controller.rb index f3ae4ead9..b2ff37a74 100644 --- a/app/controllers/api/v8/organizations/courses/submissions_controller.rb +++ b/app/controllers/api/v8/organizations/courses/submissions_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api @@ -39,6 +40,7 @@ class SubmissionsController < Api::V8::BaseController around_action :wrap_transaction + sig { void } def index unauthorize_guest! course = Course.find_by!(name: "#{params[:organization_slug]}-#{params[:course_name]}") diff --git a/app/controllers/api/v8/organizations/courses/users/points_controller.rb b/app/controllers/api/v8/organizations/courses/users/points_controller.rb index ee1534f25..1253e1a6f 100644 --- a/app/controllers/api/v8/organizations/courses/users/points_controller.rb +++ b/app/controllers/api/v8/organizations/courses/users/points_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api @@ -51,12 +52,13 @@ class PointsController < Api::V8::BaseController end end + sig { void } def index course = Course.find_by!(name: "#{params[:organization_slug]}-#{params[:course_name]}") params[:user_id] = current_user.id if params[:user_id] == 'current' points = course.awarded_points.includes(:submission).where(user_id: params[:user_id]) authorize_collection :read, points - present points.as_json_with_exercise_ids(course.exercises) + present AwardedPoint.as_json_with_exercise_ids(points, course.exercises) end end end diff --git a/app/controllers/api/v8/organizations/courses/users/progress_controller.rb b/app/controllers/api/v8/organizations/courses/users/progress_controller.rb index 98e43af65..d8a7fd265 100644 --- a/app/controllers/api/v8/organizations/courses/users/progress_controller.rb +++ b/app/controllers/api/v8/organizations/courses/users/progress_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api @@ -51,6 +52,7 @@ class ProgressController < Api::V8::BaseController end end + sig { void } def index unauthorize_guest! course = Course.find_by!(name: "#{params[:organization_slug]}-#{params[:course_name]}") @@ -64,7 +66,7 @@ def index { group: group, progress: info[:progress], - n_points: info[:awarded] + info[:late], + n_points: T.must(info[:awarded]) + T.must(info[:late]), max_points: info[:available_points], } end diff --git a/app/controllers/api/v8/organizations/courses/users/submissions_controller.rb b/app/controllers/api/v8/organizations/courses/users/submissions_controller.rb index 853589010..7aa792d96 100644 --- a/app/controllers/api/v8/organizations/courses/users/submissions_controller.rb +++ b/app/controllers/api/v8/organizations/courses/users/submissions_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api @@ -71,6 +72,7 @@ class SubmissionsController < Api::V8::BaseController around_action :wrap_transaction + sig { void } def index unauthorize_guest! course = Course.find_by!(name: "#{params[:organization_slug]}-#{params[:course_name]}") diff --git a/app/controllers/api/v8/organizations/courses_controller.rb b/app/controllers/api/v8/organizations/courses_controller.rb index 927be7147..6f420f530 100644 --- a/app/controllers/api/v8/organizations/courses_controller.rb +++ b/app/controllers/api/v8/organizations/courses_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api @@ -26,6 +27,7 @@ class CoursesController < Api::V8::BaseController end end + sig { void } def show unauthorize_guest! course = Course.find_by!(name: "#{params[:organization_slug]}-#{params[:name]}") diff --git a/app/controllers/api/v8/organizations/memberships_controller.rb b/app/controllers/api/v8/organizations/memberships_controller.rb index 99bc00803..dc0b1e6e3 100644 --- a/app/controllers/api/v8/organizations/memberships_controller.rb +++ b/app/controllers/api/v8/organizations/memberships_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api @@ -57,20 +58,7 @@ class MembershipsController < Api::V8::BaseController end end - def create - unauthorize_guest! - - organization = Organization.find_by!(slug: params[:organization_slug]) - authorize! :read, organization - - if organization.member?(current_user) - present(status: "#{current_user.email} is already a member of #{organization.name}") - else - OrganizationMembership.create! user: current_user, organization: organization - present(status: "Membership created to #{organization.name} for #{current_user.email}") - end - end - + sig { void } def index unauthorize_guest! @@ -85,6 +73,21 @@ def index end present(members: data) end + + sig { void } + def create + unauthorize_guest! + + organization = Organization.find_by!(slug: params[:organization_slug]) + authorize! :read, organization + + if organization.member?(current_user) + present(status: "#{current_user.email} is already a member of #{organization.name}") + else + OrganizationMembership.create! user: current_user, organization: organization + present(status: "Membership created to #{organization.name} for #{current_user.email}") + end + end end end end diff --git a/app/controllers/api/v8/organizations_controller.rb b/app/controllers/api/v8/organizations_controller.rb index 0b7163d1b..657c51ef6 100644 --- a/app/controllers/api/v8/organizations_controller.rb +++ b/app/controllers/api/v8/organizations_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api @@ -45,6 +46,7 @@ class OrganizationsController < Api::V8::BaseController end end + sig { void } def index orgs = Organization .visible_organizations @@ -60,6 +62,7 @@ def index present(orgs) end + sig { void } def show org = Organization.find_by!(slug: params[:slug]) authorize! :read, org diff --git a/app/controllers/api/v8/user_app_datum_controller.rb b/app/controllers/api/v8/user_app_datum_controller.rb index 52303c8a2..5abd1049d 100644 --- a/app/controllers/api/v8/user_app_datum_controller.rb +++ b/app/controllers/api/v8/user_app_datum_controller.rb @@ -1,9 +1,11 @@ +# typed: strict # frozen_string_literal: true module Api module V8 class UserAppDatumController < Api::V8::BaseController skip_authorization_check + sig { void } def index only_admins! diff --git a/app/controllers/api/v8/user_field_value_controller.rb b/app/controllers/api/v8/user_field_value_controller.rb index 489c97a41..2a8a581d1 100644 --- a/app/controllers/api/v8/user_field_value_controller.rb +++ b/app/controllers/api/v8/user_field_value_controller.rb @@ -1,9 +1,11 @@ +# typed: strict # frozen_string_literal: true module Api module V8 class UserFieldValueController < Api::V8::BaseController skip_authorization_check + sig { void } def index only_admins! diff --git a/app/controllers/api/v8/users/assistantships_controller.rb b/app/controllers/api/v8/users/assistantships_controller.rb index 2b7b6378d..4c66916c4 100644 --- a/app/controllers/api/v8/users/assistantships_controller.rb +++ b/app/controllers/api/v8/users/assistantships_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api @@ -66,6 +67,7 @@ class AssistantshipsController < Api::V8::BaseController around_action :wrap_transaction + sig { void } def index unauthorize_guest! @@ -73,7 +75,7 @@ def index user = User.find(params[:user_id]) authorize! :read, user - assisted_courses = user.assistantships.pluck(:course_id) + assisted_courses = T.let(user.assistantships.pluck(:course_id), T::Array[Integer]) readable = Course.find(assisted_courses).map do |c| { 'course_id': c.id, diff --git a/app/controllers/api/v8/users/basic_info_by_emails_controller.rb b/app/controllers/api/v8/users/basic_info_by_emails_controller.rb index e3b3572e6..1349f9b24 100644 --- a/app/controllers/api/v8/users/basic_info_by_emails_controller.rb +++ b/app/controllers/api/v8/users/basic_info_by_emails_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api @@ -58,11 +59,12 @@ class BasicInfoByEmailsController < Api::V8::BaseController skip_authorization_check + sig { void } def create return respond_forbidden unless current_user.administrator? - users = params[:emails].map do |email| + users = params[:emails].filter_map do |email| User.find_by('lower(email) = ?', email.downcase) - end.compact + end user_id_to_extra_fields = nil if params[:extra_fields] namespace = params[:extra_fields] @@ -78,10 +80,10 @@ def create } if user_id_to_extra_fields extra_fields = user_id_to_extra_fields[u.id] || [] - d[:extra_fields] = extra_fields.map { |o| [o.field_name, o.value] }.to_h + d[:extra_fields] = extra_fields.to_h { |o| [o.field_name, o.value] } end if params[:user_fields] - user_fields = u.user_field_values.map { |o| [o.field_name, o.value] }.to_h + user_fields = u.user_field_values.to_h { |o| [o.field_name, o.value] } d[:user_fields] = user_fields d[:student_number] = user_fields['organizational_id'] d[:first_name] = user_fields['first_name'] diff --git a/app/controllers/api/v8/users/basic_info_by_ids_controller.rb b/app/controllers/api/v8/users/basic_info_by_ids_controller.rb index b7f51e7b9..961608d75 100644 --- a/app/controllers/api/v8/users/basic_info_by_ids_controller.rb +++ b/app/controllers/api/v8/users/basic_info_by_ids_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api @@ -6,6 +7,7 @@ module Users class BasicInfoByIdsController < Api::V8::BaseController skip_authorization_check + sig { void } def create return respond_forbidden unless current_user.administrator? users = if params[:extra_fields] @@ -28,10 +30,10 @@ def create } if user_id_to_extra_fields extra_fields = user_id_to_extra_fields[u.id] || [] - d[:extra_fields] = extra_fields.map { |o| [o.field_name, o.value] }.to_h + d[:extra_fields] = extra_fields.to_h { |o| [o.field_name, o.value] } end if params[:user_fields] - user_fields = u.user_field_values.map { |o| [o.field_name, o.value] }.to_h + user_fields = u.user_field_values.to_h { |o| [o.field_name, o.value] } d[:user_fields] = user_fields d[:student_number] = user_fields['organizational_id'] d[:first_name] = user_fields['first_name'] diff --git a/app/controllers/api/v8/users/basic_info_by_usernames_controller.rb b/app/controllers/api/v8/users/basic_info_by_usernames_controller.rb index e8a2186f0..652f71415 100644 --- a/app/controllers/api/v8/users/basic_info_by_usernames_controller.rb +++ b/app/controllers/api/v8/users/basic_info_by_usernames_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api @@ -58,6 +59,7 @@ class BasicInfoByUsernamesController < Api::V8::BaseController skip_authorization_check + sig { void } def create return respond_forbidden unless current_user.administrator? users = if params[:extra_fields] @@ -80,10 +82,10 @@ def create } if user_id_to_extra_fields extra_fields = user_id_to_extra_fields[u.id] || [] - d[:extra_fields] = extra_fields.map { |o| [o.field_name, o.value] }.to_h + d[:extra_fields] = extra_fields.to_h { |o| [o.field_name, o.value] } end if params[:user_fields] - user_fields = u.user_field_values.map { |o| [o.field_name, o.value] }.to_h + user_fields = u.user_field_values.to_h { |o| [o.field_name, o.value] } d[:user_fields] = user_fields d[:student_number] = user_fields['organizational_id'] d[:first_name] = user_fields['first_name'] diff --git a/app/controllers/api/v8/users/password_reset_controller.rb b/app/controllers/api/v8/users/password_reset_controller.rb index 35f494b15..8acc23cea 100644 --- a/app/controllers/api/v8/users/password_reset_controller.rb +++ b/app/controllers/api/v8/users/password_reset_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api @@ -6,16 +7,17 @@ module Users class PasswordResetController < Api::V8::BaseController skip_authorization_check + sig { void } def create - @email = params['email'].to_s.strip - if @email.empty? + email = params['email'].to_s.strip + if email.empty? return render json: { success: false, errors: 'No email address provided' } end - user = User.find_by('lower(email) = ?', @email.downcase) + user = User.find_by('lower(email) = ?', email.downcase) unless user return render json: { success: false, @@ -25,7 +27,7 @@ def create key = ActionToken.generate_password_reset_key_for(user) # TODO: Whitelist origins - PasswordResetKeyMailer.reset_link_email(user, key, params['origin']).deliver + PasswordResetKeyMailer.reset_link_email(user, key, params['origin']).deliver_now render json: { success: true } diff --git a/app/controllers/api/v8/users/recently_changed_user_details_controller.rb b/app/controllers/api/v8/users/recently_changed_user_details_controller.rb index ff29f5d10..01efe0b09 100644 --- a/app/controllers/api/v8/users/recently_changed_user_details_controller.rb +++ b/app/controllers/api/v8/users/recently_changed_user_details_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api @@ -6,10 +7,11 @@ module Users class RecentlyChangedUserDetailsController < Api::V8::BaseController skip_authorization_check + sig { void } def index return respond_forbidden unless current_user.administrator? - RecentlyChangedUserDetail.deleted.where('created_at < ?', DateTime.now - 1.week).update(email: nil) - RecentlyChangedUserDetail.email_changed.where('created_at < ?', DateTime.now - 1.month).delete_all + RecentlyChangedUserDetail.deleted.where(created_at: ...(DateTime.now - 1.week)).update(:all, email: nil) + RecentlyChangedUserDetail.email_changed.where(created_at: ...(DateTime.now - 1.month)).delete_all render json: { changes: RecentlyChangedUserDetail.all } end end diff --git a/app/controllers/api/v8/users/request_deletion_controller.rb b/app/controllers/api/v8/users/request_deletion_controller.rb index c1baf2c2d..cf683195c 100644 --- a/app/controllers/api/v8/users/request_deletion_controller.rb +++ b/app/controllers/api/v8/users/request_deletion_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api @@ -6,6 +7,7 @@ module Users class RequestDeletionController < Api::V8::BaseController skip_authorization_check + sig { void } def create user = current_user user = User.find_by!(id: params[:user_id]) unless params[:user_id] == 'current' diff --git a/app/controllers/api/v8/users/teacherships_controller.rb b/app/controllers/api/v8/users/teacherships_controller.rb index 9469ad468..ad83061d4 100644 --- a/app/controllers/api/v8/users/teacherships_controller.rb +++ b/app/controllers/api/v8/users/teacherships_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true module Api @@ -66,6 +67,7 @@ class TeachershipsController < Api::V8::BaseController around_action :wrap_transaction + sig { void } def index unauthorize_guest! @@ -73,7 +75,7 @@ def index user = User.find(params[:user_id]) authorize! :read, user - taught_organizations = user.teacherships.pluck(:organization_id) + taught_organizations = T.let(user.teacherships.pluck(:organization_id), T::Array[Integer]) readable = Organization.find(taught_organizations).map do |o| { 'organization_id': o.id, diff --git a/app/controllers/api/v8/users_controller.rb b/app/controllers/api/v8/users_controller.rb index 16d110f7d..40d491526 100644 --- a/app/controllers/api/v8/users_controller.rb +++ b/app/controllers/api/v8/users_controller.rb @@ -1,8 +1,10 @@ +# typed: strict # frozen_string_literal: true module Api module V8 class UsersController < Api::V8::BaseController + extend T::Sig include Swagger::Blocks swagger_path '/api/v8/users/{user_id}' do @@ -111,6 +113,7 @@ class UsersController < Api::V8::BaseController skip_authorization_check only: %i[set_password_managed_by_courses_mooc_fi destroy] + sig { void } def show unauthorize_guest! if current_user.guest? user = current_user @@ -125,7 +128,7 @@ def show } if params[:show_user_fields] - user_field = {} + user_field = T.let({}, T::Hash[String, T.any(String, T::Boolean)]) UserField.all.select { |f| f.visible_to?(current_user) }.each do |field| value_record = user.field_value_record(field) value = value_record.ruby_value @@ -135,9 +138,9 @@ def show end if params[:extra_fields] - extra_fields = {} + extra_fields = T.let({}, T::Hash[T.nilable(String), T.nilable(String)]) namespace = params[:extra_fields] - UserAppDatum.where(namespace: namespace, user: user).each do |datum| + UserAppDatum.where(namespace: namespace, user: user).find_each do |datum| extra_fields[datum.field_name] = datum.value end data[:extra_fields] = extra_fields @@ -145,71 +148,78 @@ def show render json: data end + sig { void } def create authorize! :create, User - @user = User.new + user = User.new - @user.login = SecureRandom.uuid + user.login = SecureRandom.uuid - set_email - set_password - set_user_fields - set_extra_data + set_email(user) + set_password(user) + set_user_fields(user) + set_extra_data(user) - if BannedEmail.banned?(@user.email) - return render json: build_success_response(params[:include_id]) + if BannedEmail.banned?(user.email) + return render json: build_success_response(user, params[:include_id]) end - if @user.errors.empty? && @user.save + if user.errors.empty? && user.save # TODO: Whitelist origins - UserMailer.email_confirmation(@user, params[:origin], params[:language]).deliver_now + UserMailer.email_confirmation(user, params[:origin], params[:language]).deliver_now # Post the new user to courses.mooc.fi for password management if params[:user][:password].present? - @user.post_new_user_to_courses_mooc_fi(params[:user][:password]) + user.post_new_user_to_courses_mooc_fi(params[:user][:password]) end - render json: build_success_response(params[:include_id]) + render json: build_success_response(user, params[:include_id]) else - errors = @user.errors - errors[:username] = errors.delete(:login) if errors.key?(:login) + errors = user.errors + errors.delete(:login)&.each { |message| errors.add(:username, message) } render json: { success: false, - errors: @user.errors + errors: user.errors } end end + sig { void } def update unauthorize_guest! if current_user.guest? - User.transaction do - @user = current_user - @user = User.find_by!(id: params[:id]) unless params[:id] == 'current' - @email_before = @user.email - authorize! :update, @user - set_user_fields - set_extra_data(true) - update_email - maybe_update_password - raise ActiveRecord::Rollback if !@user.errors.empty? || !@user.save + user = current_user + updated = User.transaction do + user = User.find_by!(id: params[:id]) unless params[:id] == 'current' + email_before = user.email + authorize! :update, user + set_user_fields(user) + set_extra_data(user, true) + update_email(user) + maybe_update_password(user) + raise ActiveRecord::Rollback if !user.errors.empty? || !user.save # Password changed locally: migrate the user to courses.mooc.fi - if params[:old_password].present? && params[:password].present? && !@user.managed_externally? - @user.post_new_user_to_courses_mooc_fi(params[:password]) + if params[:old_password].present? && params[:password].present? && !user.managed_externally? + user.post_new_user_to_courses_mooc_fi(params[:password]) end - RecentlyChangedUserDetail.email_changed.create!(old_value: @email_before, new_value: @user.email, username: @user.login, user_id: @user.id) unless @email_before.casecmp(@user.email).zero? - return render json: { + RecentlyChangedUserDetail.email_changed.create!(old_value: email_before, new_value: user.email, username: user.login, user_id: user.id) unless T.must(email_before.casecmp(user.email)).zero? + true + end + if updated + render json: { message: 'User details updated.' } + else + render json: { + errors: user.errors + }, status: :bad_request end - render json: { - errors: @user.errors - }, status: :bad_request end # courses.mooc.fi retries account deletion and keys off success/already_deleted/code, so a # user that is already gone must answer 200, never 404. + sig { void } def destroy return render_destroy_failure('not_authorized', 'Authentication required', :unauthorized) if current_user.guest? @@ -238,6 +248,7 @@ def destroy render_destroy_failure('internal_error', 'User deletion failed.', :internal_server_error) end + sig { void } def set_password_managed_by_courses_mooc_fi only_admins! @@ -249,22 +260,27 @@ def set_password_managed_by_courses_mooc_fi user = User.find_by!(id: params[:id]) authorize! :update, user - User.transaction do + updated = User.transaction do user.password_managed_by_courses_mooc_fi = true user.password_hash = nil user.salt = nil user.argon_hash = nil user.courses_mooc_fi_user_id = params[:courses_mooc_fi_user_id] raise ActiveRecord::Rollback if !user.errors.empty? || !user.save - return render json: { + true + end + if updated + render json: { status: 'Password managed by courses.mooc.fi set to true and password deleted.' } + else + render json: { + errors: user.errors + }, status: :bad_request end - render json: { - errors: user.errors - }, status: :bad_request end + sig { void } def get_user_with_email unauthorize_guest! if current_user.guest? @@ -283,78 +299,84 @@ def get_user_with_email end private + sig { params(code: String, messages: T.any(String, T::Array[String]), status: Symbol).void } def render_destroy_failure(code, messages, status) render json: { success: false, code: code, errors: [*messages] }, status: status end - def set_email + sig { params(user: User).void } + def set_email(user) user_params = params[:user] - return unless @user.new_record? + return unless user.new_record? if user_params[:email].blank? - @user.errors.add(:email, 'needed') + user.errors.add(:email, 'needed') else - @user.email = user_params[:email].strip + user.email = user_params[:email].strip end end - def update_email + sig { params(user: User).void } + def update_email(user) user_params = params[:user] return unless user_params[:email] new_email = user_params[:email].strip if new_email.blank? - @user.errors.add(:email, 'needed') - elsif @user.email.casecmp(new_email) != 0 - @user.email = new_email - @user.email_verified = false - UserMailer.email_confirmation(@user, params[:origin], params[:language]).deliver_now + user.errors.add(:email, 'needed') + elsif user.email.casecmp(new_email) != 0 + user.email = new_email + user.email_verified = false + UserMailer.email_confirmation(user, params[:origin], params[:language]).deliver_now end end - def set_password + sig { params(user: User).void } + def set_password(user) user_params = params[:user] if user_params[:password].blank? - @user.errors.add(:password, 'needed') + user.errors.add(:password, 'needed') elsif user_params[:password].length > 1000 - @user.errors.add(:password, 'cannot be over 1000 characters') + user.errors.add(:password, 'cannot be over 1000 characters') elsif user_params[:password] != user_params[:password_confirmation] - @user.errors.add(:password_confirmation, 'did not match') + user.errors.add(:password_confirmation, 'did not match') else - @user.password = user_params[:password] + user.password = user_params[:password] end end - def maybe_update_password + sig { params(user: User).void } + def maybe_update_password(user) if params[:old_password].present? && params[:password].present? - if @user.managed_externally? - unless @user.update_password_via_courses_mooc_fi(params[:old_password], params[:password]) - @user.errors.add(:password, 'could not be changed. Please check your current password and try again.') + if user.managed_externally? + unless user.update_password_via_courses_mooc_fi(params[:old_password], params[:password]) + user.errors.add(:password, 'could not be changed. Please check your current password and try again.') end return - elsif @user.externally_managed_without_target? - @user.errors.add(:base, 'This account is being migrated; password changes are temporarily unavailable.') + elsif user.externally_managed_without_target? + user.errors.add(:base, 'This account is being migrated; password changes are temporarily unavailable.') return end - if !@user.has_password?(params[:old_password]) - @user.errors.add(:old_password, 'incorrect') + if !user.has_password?(params[:old_password]) + user.errors.add(:old_password, 'incorrect') elsif params[:password] != params[:password_repeat] - @user.errors.add(:password_repeat, 'did not match') + user.errors.add(:password_repeat, 'did not match') elsif params[:password].blank? - @user.errors.add(:password, 'cannot be empty') + user.errors.add(:password, 'cannot be empty') elsif params[:password].length > 1000 - @user.errors.add(:password, 'cannot be over 1000 characters') + user.errors.add(:password, 'cannot be over 1000 characters') else - @user.password = params[:password] + user.password = params[:password] end end end - def set_user_fields + sig { params(user: User).returns(T.nilable(T::Hash[String, T::Hash[Symbol, T.any(String, T::Boolean)]])) } + def set_user_fields(user) return if params[:user_field].nil? - changes = {} + changes = T.let({}, T::Hash[String, T::Hash[Symbol, T.any(String, T::Boolean)]]) UserField.all.select { |f| f.visible_to?(current_user) }.each do |field| - value_record = @user.field_value_record(field) + value_record = user.field_value_record(field) old_value = value_record.ruby_value value_record.set_from_form(params[:user_field][field.name]) new_value = value_record.ruby_value @@ -363,26 +385,28 @@ def set_user_fields changes end - def set_extra_data(eager_save = false) + sig { params(user: User, eager_save: T::Boolean).void } + def set_extra_data(user, eager_save = false) return unless params['user'] extra_fields = params['user']['extra_fields'] return if extra_fields.nil? namespace = extra_fields['namespace'] raise 'Namespace not defined' unless namespace extra_fields['data'].each do |key, value| - datum = @user.user_app_data.find_or_initialize_by(namespace: namespace, field_name: key) + datum = user.user_app_data.find_or_initialize_by(namespace: namespace, field_name: key) datum.value = value datum.save! if eager_save end end - def build_success_response(include_id = false) - response = { + sig { params(user: User, include_id: T.untyped).returns(T::Hash[Symbol, T.untyped]) } + def build_success_response(user, include_id = false) + response = T.let({ success: true, message: 'User created.', - } + }, T::Hash[Symbol, T.untyped]) if include_id - response[:id] = @user.id + response[:id] = user.id end response end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 4e307cb1d..071b8dcd5 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require 'version' @@ -8,6 +9,7 @@ # Includes common parts like handling some exceptions as well as # various utility methods. class ApplicationController < ActionController::Base + extend T::Sig include BreadCrumbs helper :all @@ -20,13 +22,12 @@ class ApplicationController < ActionController::Base include BootstrapFlashHelper include FlashBlockHelper include SessionsHelper - include BreadcrumbHelpers - include EmbeddableHelper include AuthorizeCollectionHelper include ApplicationHelper check_authorization rescue_from CanCan::AccessDenied do |_exception| + T.bind(self, ApplicationController) if current_user.guest? redirect_to(login_path(return_to: return_to_link)) else @@ -35,18 +36,22 @@ class ApplicationController < ActionController::Base end rescue_from ActiveRecord::RecordNotFound do |exception| + T.bind(self, ApplicationController) respond_with_error('Not found', 404, exception) end rescue_from ActionController::MissingFile do |exception| + T.bind(self, ApplicationController) respond_with_error('File not found', 404, exception) end rescue_from Rack::Timeout::RequestTimeoutException do |exception| + T.bind(self, ApplicationController) respond_with_error('Request timed out', 504, exception) # status code 408 would be the correct status code, but when receiving 408 Firefox retries 10 times and then states the connection was reset instead of showing our error end rescue_from Rack::Timeout::RequestTimeoutError do |exception| + T.bind(self, ApplicationController) respond_with_error('Request timed out', 504, exception) # status code 408 would be the correct status code, but when receiving 408 Firefox retries 10 times and then states the connection was reset instead of showing our error end @@ -56,6 +61,17 @@ class ApplicationController < ActionController::Base before_action :set_controller_and_action_name before_action :setup_rack_mini_profiler + sig { void } + def initialize + super + # Declared here because the breadcrumb helpers read them; subclasses assign them. + @organization = T.let(nil, T.nilable(Organization)) + @course = T.let(nil, T.nilable(Course)) + @exercise = T.let(nil, T.nilable(Exercise)) + @submission = T.let(nil, T.nilable(Submission)) + end + + sig { returns(T::Hash[Symbol, T.untyped]) } def url_options if @bare_layout { bare_layout: '1' }.merge(super) @@ -65,14 +81,17 @@ def url_options end private + sig { returns(::Ability) } def current_ability - @current_ability ||= ::Ability.new(current_user) + @current_ability ||= T.let(::Ability.new(current_user), T.nilable(::Ability)) end + sig { void } def set_default_url_options Rails.application.routes.default_url_options[:host] = request.host_with_port end + sig { void } def check_api_version if should_check_api_version? if params[:api_version].blank? @@ -91,6 +110,7 @@ def check_api_version end end + sig { params(client_name: T.untyped, client_version: T.untyped).void } def check_client_version(client_name, client_version) begin client_version = Version.new(client_version) unless client_version.nil? @@ -111,6 +131,7 @@ def check_client_version(client_name, client_version) end end + sig { returns(T::Boolean) } def should_check_api_version? params[:format] == 'json' && controller_name != 'stats' && @@ -119,49 +140,65 @@ def should_check_api_version? !(controller_path.starts_with? 'api') end + sig { void } def set_bare_layout - @bare_layout = !!params[:bare_layout] + @bare_layout = T.let(!!params[:bare_layout], T.nilable(T::Boolean)) end + sig { void } + def remove_x_frame_options_header_when_bare_layout + response.headers.except! 'X-Frame-Options' if @bare_layout + end + + sig { void } def set_controller_and_action_name - @controller_name = controller_name - @action_name = action_name + @controller_name = T.let(controller_name, T.nilable(String)) + @action_name = T.let(action_name, T.nilable(String)) end + sig { void } def setup_rack_mini_profiler - if current_user && current_user.administrator? + if current_user.administrator? Rack::MiniProfiler.authorize_request end end + sig { params(message: T.nilable(String)).returns(T.noreturn) } def unauthorized!(message = nil) raise CanCan::AccessDenied, message end + sig { params(message: String).void } def unauthorize_guest!(message = 'Authentication required') unauthorized!(message) if current_user.guest? end + sig { void } def only_admins! unauthorized! unless current_user.administrator? end + sig { void } def authorization_skip! authorize! :read, nil end + sig { params(msg: String).void } def respond_not_found(msg = 'Not Found') respond_with_error(msg, 404) end + sig { params(msg: String).void } def respond_forbidden(msg = 'Forbidden') respond_with_error(msg, 403) end + sig { params(msg: String).void } def respond_unauthorized(msg = 'Authentication required') respond_with_error(msg, 401) end + sig { params(msg: String, code: Integer, exception: T.nilable(Exception), extra_json_keys: T::Hash[Symbol, T.untyped]).void } def respond_with_error(msg, code = 500, exception = nil, extra_json_keys = {}) respond_to do |format| format.html do @@ -190,16 +227,25 @@ def respond_with_error(msg, code = 500, exception = nil, extra_json_keys = {}) end # To support older versions of tmc-netbeans-plugin + sig { returns(T::Boolean) } def client_supports_http_basic_auth? - return true if params[:client].blank? - client = params[:client] + params[:client] != 'netbeans_plugin' || netbeans_plugin_at_least?('0.8.0') + end + + # Whether the request comes from tmc-netbeans-plugin +min_version+ or newer. + # A NetBeans plugin that sends no parseable version counts as older. + sig { params(min_version: String).returns(T::Boolean) } + def netbeans_plugin_at_least?(min_version) + return false unless params[:client] == 'netbeans_plugin' client_version = begin Version.new(params['client_version']) if params['client_version'].present? rescue StandardError + nil end - !(client == 'netbeans_plugin' && client_version < Version.new('0.8.0')) + !client_version.nil? && client_version >= Version.new(min_version) end + sig { returns(String) } def select_layout if params[:bare_layout] 'bare' @@ -208,16 +254,19 @@ def select_layout end end + # permitted: :all, or the field names without the prefix. + sig { params(prefix: String, permitted: T.any(Symbol, T::Array[String]), options: T::Hash[Symbol, T.untyped]).returns(T::Hash[String, T.untyped]) } def params_starting_with(prefix, permitted, options = {}) options = { remove_prefix: false }.merge(options) - permitted = permitted.map { |f| prefix + f } unless permitted == :all + permitted = permitted.map { |f| prefix + f } if permitted.is_a?(Array) - result = Hash[params.select do |k, v| - k.start_with?(prefix) && v.present? && (permitted == :all || permitted.include?(k)) - end.permit(permitted)] + selected = params.select do |k, v| + k.start_with?(prefix) && v.present? && (permitted == :all || (permitted.is_a?(Array) && permitted.include?(k))) + end + result = Hash[selected.permit(*selected.keys)] if options[:remove_prefix] result = result.transform_keys { |k| k.sub(/^#{prefix}/, '') } end @@ -226,6 +275,7 @@ def params_starting_with(prefix, permitted, options = {}) # To be called from a respond_to on csv. # http://stackoverflow.com/questions/94502/in-rails-how-to-return-records-as-a-csv-file + sig { params(options: T::Hash[Symbol, T.untyped]).void } def render_csv(options = {}) options = { filename: action_name @@ -250,7 +300,39 @@ def render_csv(options = {}) render render_options end + sig { params(block: T.proc.void).void } def wrap_transaction(&block) ActiveRecord::Base.transaction(requires_new: true, &block) end + + sig { void } + def add_organization_breadcrumb + organization = T.must(@organization) + add_breadcrumb "Organization #{organization.name}", organization_path(organization) + end + + sig { void } + def add_course_breadcrumb + add_organization_breadcrumb + course = T.must(@course) + add_breadcrumb "Course #{course.name}", organization_course_path(@organization, course) + end + + sig { void } + def add_exercise_breadcrumb + exercise = @exercise || @submission&.exercise + if exercise + add_breadcrumb "Exercise #{exercise.name}", exercise_path(exercise) + elsif @submission&.exercise_name + add_breadcrumb "(deleted exercise #{@submission.exercise_name})" + else + raise 'Neither @exercise nor @submission.exercise_name set' + end + end + + sig { void } + def add_submission_breadcrumb + submission = T.must(@submission) + add_breadcrumb "Submission ##{submission.id}", submission_path(submission) + end end diff --git a/app/controllers/auths_controller.rb b/app/controllers/auths_controller.rb index e79611689..859f3107c 100644 --- a/app/controllers/auths_controller.rb +++ b/app/controllers/auths_controller.rb @@ -1,13 +1,16 @@ +# typed: strict # frozen_string_literal: true # Provides an authentication service. class AuthsController < ApplicationController + extend T::Sig OK_MESSAGE = 'OK' FAIL_MESSAGE = 'FAIL' skip_authorization_check skip_before_action :verify_authenticity_token + sig { void } def show if params[:username].present? && params[:session_id].present? user = User.find_by(login: params[:username]) @@ -42,6 +45,7 @@ def show end private + sig { params(sid: T.untyped).returns(T.nilable(Session)) } def find_session_by_id(sid) # Can't say Session.find_by_session_id because of a nasty metaprogramming hax in AR's superclass. Session.where(session_id: sid).first diff --git a/app/controllers/breadcrumb_helpers.rb b/app/controllers/breadcrumb_helpers.rb deleted file mode 100644 index 06f98ae4f..000000000 --- a/app/controllers/breadcrumb_helpers.rb +++ /dev/null @@ -1,32 +0,0 @@ - -# frozen_string_literal: true - -# Helper methods for making breadcrumbs. -# -# See https://github.com/weppos/breadcrumbs_on_rails -module BreadcrumbHelpers # Included in ApplicationController - def add_organization_breadcrumb - add_breadcrumb "Organization #{@organization.name}", organization_path(@organization) - end - - def add_course_breadcrumb - add_organization_breadcrumb - add_breadcrumb "Course #{@course.name}", organization_course_path(@organization, @course) - end - - def add_exercise_breadcrumb - if @exercise - add_breadcrumb "Exercise #{@exercise.name}", exercise_path(@exercise) - elsif @submission&.exercise - add_breadcrumb "Exercise #{@submission.exercise.name}", exercise_path(@submission.exercise) - elsif @submission&.exercise_name - add_breadcrumb "(deleted exercise #{@submission.exercise_name}" - else - raise 'Neither @exercise nor @submission.exercise_name set' - end - end - - def add_submission_breadcrumb - add_breadcrumb "Submission ##{@submission.id}", submission_path(@submission) - end -end diff --git a/app/controllers/certificates_controller.rb b/app/controllers/certificates_controller.rb index afcbaeadf..479293108 100644 --- a/app/controllers/certificates_controller.rb +++ b/app/controllers/certificates_controller.rb @@ -1,21 +1,27 @@ +# typed: strict # frozen_string_literal: true class CertificatesController < ApplicationController + extend T::Sig before_action :set_user, except: [:show] before_action :set_courses, except: [:show] before_action :add_certificate_breadcrumbs, except: [:show] + sig { void } def index authorize! :read, @user - @certificates = @user.certificates.order(:created_at).includes(:course) - @names = @certificates.map(&:name).uniq.sort + certificates = T.must(@user).certificates.order(:created_at).includes(:course) + @certificates = T.let(certificates, T.nilable(ActiveRecord::Relation)) + @names = T.let(certificates.map(&:name).uniq.sort, T.nilable(T::Array[T.nilable(String)])) - @certificate = Certificate.new(user: @user) + certificate = Certificate.new(user: @user) + @certificate = T.let(certificate, T.nilable(Certificate)) - last_certificate = @certificates.last - @certificate.name = last_certificate.name if last_certificate + last_certificate = certificates.last + certificate.name = last_certificate.name if last_certificate end + sig { void } def show certificate = Certificate.find params[:id] authorize! :read, certificate @@ -23,6 +29,7 @@ def show send_data certificate.pdf, type: :pdf, disposition: 'inline' end + sig { void } def create @certificate = Certificate.new certificate_params authorize! :read, @certificate.user @@ -39,25 +46,31 @@ def create end private + sig { returns(ActionController::Parameters) } def certificate_params params.require(:certificate).permit(:name, :user_id, :course_id) end + sig { void } def set_user id = params[:participant_id] || params[:certificate][:user_id] - @user = User.find(id) + @user = T.let(User.find(id), T.nilable(User)) end + sig { void } def set_courses - @courses = current_user.administrator? ? Course.where(certificate_downloadable: true).order(:name) : Course.with_certificates_for(@user) + courses = current_user.administrator? ? Course.where(certificate_downloadable: true).order(:name) : Course.with_certificates_for(@user) + @courses = T.let(courses, T.nilable(T::Enumerable[Course])) end + sig { void } def add_certificate_breadcrumbs - if @user == current_user - add_breadcrumb 'My stats', participant_path(@user) + user = T.must(@user) + if user == current_user + add_breadcrumb 'My stats', participant_path(user) elsif current_user.administrator? add_breadcrumb 'Participants', :participants_path - add_breadcrumb @user.display_name, participant_path(@user) + add_breadcrumb user.display_name, participant_path(user) end add_breadcrumb 'Certificate' end diff --git a/app/controllers/course_notifications_controller.rb b/app/controllers/course_notifications_controller.rb index 167c1ea04..54803e0f0 100644 --- a/app/controllers/course_notifications_controller.rb +++ b/app/controllers/course_notifications_controller.rb @@ -1,26 +1,31 @@ +# typed: strict # frozen_string_literal: true # Handles emailing notification to every participant class CourseNotificationsController < ApplicationController + extend T::Sig before_action :set_organization + sig { void } def new course = Course.find(course_notification_params[:course_id]) authorize! :send_mail_to_participants, course - @notifier ||= CourseNotification.new + @notifier ||= T.let(CourseNotification.new, T.nilable(CourseNotification)) @course = Course.find_by(id: params[:course_id]) add_course_breadcrumb add_breadcrumb 'Course notification' end + sig { void } def create course = Course.find(course_notification_params[:course_id]) authorize! :send_mail_to_participants, course participants = User.course_students(course) - emails = participants.map(&:email).reject(&:blank?) + emails = participants.map(&:email).compact_blank - notifier = course.course_notifications.create(course_notification_params[:course_notification].merge(sender_id: current_user.id)) + notification_attributes = T.let(course_notification_params[:course_notification].merge(sender_id: current_user.id), ActionController::Parameters) + notifier = course.course_notifications.create(notification_attributes) if notifier.message.blank? flash[:error] = 'Cannot send a blank message.' @@ -29,7 +34,7 @@ def create failed_emails = [] emails.each do |email| - raise 'Invalid e-mail' unless email =~ /\S+@\S+/ + raise 'Invalid e-mail' unless /\S+@\S+/.match?(email) CourseNotificationMailer.notification_email( reply_to: current_user.email, to: email, @@ -46,11 +51,13 @@ def create end private + sig { returns(ActionController::Parameters) } def course_notification_params params.permit(:commit, :course_id, course_notification: %i[topic message]) end + sig { void } def set_organization - @organization = Organization.find_by(slug: params[:organization_id]) + @organization = Organization.find_by!(slug: params[:organization_id]) end end diff --git a/app/controllers/course_templates_controller.rb b/app/controllers/course_templates_controller.rb index b76a576ed..8467edae9 100644 --- a/app/controllers/course_templates_controller.rb +++ b/app/controllers/course_templates_controller.rb @@ -1,25 +1,30 @@ +# typed: strict # frozen_string_literal: true class CourseTemplatesController < ApplicationController + extend T::Sig before_action :set_course_template, except: %i[index new create list_for_teachers] + sig { void } def index authorize! :read, CourseTemplate ordering = Arel.sql('LOWER(name)') add_breadcrumb 'Course templates', course_templates_path - @course_templates = CourseTemplate.not_hidden.not_dummy.order(ordering) - @hidden_course_templates = CourseTemplate.hidden.not_dummy.order(ordering) + @course_templates = T.let(CourseTemplate.not_hidden.not_dummy.order(ordering), T.nilable(ActiveRecord::Relation)) + @hidden_course_templates = T.let(CourseTemplate.hidden.not_dummy.order(ordering), T.nilable(ActiveRecord::Relation)) if request.params[:generate_report] - @refresh_report = CourseTemplateRefresh.find(request.params[:generate_report]) + @refresh_report = T.let(CourseTemplateRefresh.find(request.params[:generate_report]), T.nilable(CourseTemplateRefresh)) end end + sig { void } def show authorize! :read, CourseTemplate add_breadcrumb 'Course templates', course_templates_path - add_breadcrumb @course_template.title.to_s + add_breadcrumb T.must(@course_template).title.to_s end + sig { void } def new authorize! :create, CourseTemplate add_breadcrumb 'Course templates', course_templates_path @@ -27,12 +32,14 @@ def new @course_template = CourseTemplate.new end + sig { void } def edit authorize! :edit, CourseTemplate add_breadcrumb 'Course templates', course_templates_path add_breadcrumb 'Edit Course template' end + sig { void } def create authorize! :create, CourseTemplate @course_template = CourseTemplate.new(course_template_params) @@ -44,46 +51,56 @@ def create end end + sig { void } def update authorize! :edit, CourseTemplate - if @course_template.update(course_template_params) - redirect_to course_templates_path, notice: "Course template '#{@course_template.name}' was successfully updated." + course_template = T.must(@course_template) + if course_template.update(course_template_params) + redirect_to course_templates_path, notice: "Course template '#{course_template.name}' was successfully updated." else render :edit end end + sig { returns(T.noreturn) } def destroy raise 'One does not destroy a course template' end + sig { void } def list_for_teachers - @organization = Organization.find_by(slug: params[:organization_id]) + @organization = Organization.find_by!(slug: params[:organization_id]) authorize! :teach, @organization add_organization_breadcrumb add_breadcrumb 'Course templates' @course_templates = CourseTemplate.available.order(Arel.sql('LOWER(title)')) end + sig { void } def toggle_hidden - @course_template.hidden = !@course_template.hidden - @course_template.save + course_template = T.must(@course_template) + course_template.hidden = !course_template.hidden + course_template.save redirect_to course_templates_path, notice: 'Course templates hidden status changed' end + sig { void } def refresh authorize! :create, CourseTemplate - notice = "Refresh initialized for course template #{@course_template.name}" - @course_template.refresh(current_user.id) - redirect_to course_templates_path(id: @course_template.id), notice: notice + course_template = T.must(@course_template) + notice = "Refresh initialized for course template #{course_template.name}" + course_template.refresh(current_user.id) + redirect_to course_templates_path(id: course_template.id), notice: notice end private + sig { void } def set_course_template authorize! params[:action].to_sym, CourseTemplate - @course_template = CourseTemplate.find(params[:id]) + @course_template = T.let(CourseTemplate.find(params[:id]), T.nilable(CourseTemplate)) end + sig { returns(ActionController::Parameters) } def course_template_params params.require(:course_template).permit(:name, :title, :description, :material_url, :source_url, :expires_at, :git_branch) end diff --git a/app/controllers/courses_controller.rb b/app/controllers/courses_controller.rb index 48fb55028..f057d2145 100644 --- a/app/controllers/courses_controller.rb +++ b/app/controllers/courses_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require 'natsort' @@ -7,11 +8,13 @@ require 'course_info' class CoursesController < ApplicationController + extend T::Sig before_action :set_organization before_action :set_course, except: %i[help index show_json] skip_authorization_check only: [:index] + sig { void } def index ordering = Arel.sql('hidden, disabled_status, LOWER(name)') @@ -20,7 +23,8 @@ def index redirect_to organization_path(@organization) end format.json do - courses = @organization.courses.ongoing.order(ordering) + organization = T.must(@organization) + courses = organization.courses.ongoing.order(ordering) courses = courses.select { |c| c.visible_to?(current_user) } authorize! :read, courses return respond_unauthorized('Authentication required') if current_user.guest? @@ -28,19 +32,21 @@ def index data = { api_version: ApiVersion::API_VERSION, - courses: CourseList.new(current_user, view_context).course_list_data(@organization, courses, opts) + courses: CourseList.new(current_user, view_context).course_list_data(organization, courses, opts) } render json: data.to_json end end end + sig { void } def show authorize! :read, @course + course = T.must(@course) - if (request.params[:generate_report]) && (can? :teach, @course) + if (request.params[:generate_report]) && (can? :teach, course) report = CourseTemplateRefresh.find(request.params[:generate_report]) - @refresh_report = report if report.course_template_id == @course.course_template_id + @refresh_report = T.let(report, T.nilable(CourseTemplateRefresh)) if report.course_template_id == course.course_template_id end respond_to do |format| @@ -53,7 +59,7 @@ def show opts = { include_points: !!params[:show_points], include_unlock_conditions: !!params[:show_unlock_conditions] } data = { api_version: ApiVersion::API_VERSION, - course: CourseInfo.new(current_user, view_context).course_data(@organization, @course, opts) + course: CourseInfo.new(current_user, view_context).course_data(T.must(@organization), course, opts) } render json: data.to_json end @@ -61,6 +67,7 @@ def show end # Method for teacher to give a single course for students to select. + sig { void } def show_json course = [Course.find(params[:id])] authorize! :read, course @@ -68,41 +75,52 @@ def show_json data = { api_version: ApiVersion::API_VERSION, - courses: CourseList.new(current_user, view_context).course_list_data(@organization, course) + courses: CourseList.new(current_user, view_context).course_list_data(T.must(@organization), course) } render json: data.to_json end + sig { void } def refresh authorize! :refresh, @course - refresh_course(@course) - redirect_to(organization_course_path(@organization, @course), notice: 'Refresh initialized, please wait') + course = T.must(@course) + refresh_course(course) + redirect_to(organization_course_path(@organization, course), notice: 'Refresh initialized, please wait') end + sig { void } def enable authorize! :teach, @organization # should assistants be able to enable/disable? - @course.enabled! - redirect_to(organization_course_path(@organization, @course), notice: 'Course was successfully enabled.') + course = T.must(@course) + course.enabled! + redirect_to(organization_course_path(@organization, course), notice: 'Course was successfully enabled.') end + sig { void } def disable authorize! :teach, @organization - @course.disabled! - redirect_to(organization_course_path(@organization, @course), notice: 'Course was successfully disabled.') + course = T.must(@course) + course.disabled! + redirect_to(organization_course_path(@organization, course), notice: 'Course was successfully disabled.') end + sig { void } def toggle_hidden authorize! :teach, @organization - @course.update!(hidden: !@course.hidden) - redirect_to(organization_course_path(@organization, @course), notice: "Course is now #{@course.hidden? ? 'hidden' : 'visible'}.") + course = T.must(@course) + course.update!(hidden: !course.hidden) + redirect_to(organization_course_path(@organization, course), notice: "Course is now #{course.hidden? ? 'hidden' : 'visible'}.") end + sig { void } def toggle_code_review_requests authorize! :teach, @organization - @course.update!(code_review_requests_enabled: !@course.code_review_requests_enabled) - redirect_to(organization_course_path(@organization, @course), notice: "Code review requests are now #{@course.code_review_requests_enabled? ? 'enabled' : 'disabled'}.") + course = T.must(@course) + course.update!(code_review_requests_enabled: !course.code_review_requests_enabled) + redirect_to(organization_course_path(@organization, course), notice: "Code review requests are now #{course.code_review_requests_enabled? ? 'enabled' : 'disabled'}.") end + sig { void } def manage_deadlines authorize! :manage_deadlines, @course add_course_breadcrumb @@ -110,15 +128,18 @@ def manage_deadlines assign_show_view_vars end + sig { void } def save_deadlines authorize! :manage_deadlines, @course + course = T.must(@course) groups = group_params groups.each do |name, deadlines| - soft_deadlines = [deadlines[:soft][:static], deadlines[:soft][:unlock]].to_json - hard_deadlines = [deadlines[:hard][:static], deadlines[:hard][:unlock]].to_json - @course.exercise_group_by_name(name).soft_group_deadline = soft_deadlines - @course.exercise_group_by_name(name).hard_group_deadline = hard_deadlines + # The form may name groups that a refresh removed after it was rendered. + group = course.exercise_group_by_name(name) + next if group.nil? + group.soft_group_deadline = [deadlines[:soft][:static], deadlines[:soft][:unlock]].to_json + group.hard_group_deadline = [deadlines[:hard][:static], deadlines[:hard][:unlock]].to_json end exercises = params[:exercise] || {} @@ -126,18 +147,19 @@ def save_deadlines soft_deadlines = [deadlines[:soft][:static], deadlines[:soft][:unlock]].to_json hard_deadlines = [deadlines[:hard][:static], deadlines[:hard][:unlock]].to_json - exercise = Exercise.where(course_id: @course.id).find_by(name: name) + exercise = Exercise.where(course_id: course.id).find_by(name: name) next if exercise.nil? exercise.soft_deadline_spec = soft_deadlines exercise.deadline_spec = hard_deadlines exercise.save! end - redirect_to manage_deadlines_organization_course_path(@organization, @course), notice: 'Successfully saved deadlines.' + redirect_to manage_deadlines_organization_course_path(@organization, course), notice: 'Successfully saved deadlines.' rescue DeadlineSpec::InvalidSyntaxError => e - redirect_to manage_deadlines_organization_course_path(@organization, @course), alert: e.to_s + redirect_to manage_deadlines_organization_course_path(@organization, course), alert: e.to_s end + sig { void } def help @course = Course.find(params[:course_id]) authorize! :read, @course @@ -145,6 +167,7 @@ def help add_breadcrumb 'Help page' end + sig { void } def manage_unlocks authorize! :manage_unlocks, @course add_course_breadcrumb @@ -152,84 +175,100 @@ def manage_unlocks assign_show_view_vars end + sig { void } def save_unlocks authorize! :manage_unlocks, @course + course = T.must(@course) begin group_params.each do |name, conditions| - array = conditions.values.reject(&:blank?) - group = @course.exercise_group_by_name(name) - Rails.logger.info "Unlock conditions of #{@course.name}/#{name} set by user #{current_user.id}: #{group.exercises(false).first&.unlock_spec || '[]'} -> #{array.to_json}" + group = course.exercise_group_by_name(name) + next if group.nil? + array = conditions.values.compact_blank + Rails.logger.info "Unlock conditions of #{course.name}/#{name} set by user #{current_user.id}: #{group.exercises(false).first&.unlock_spec || '[]'} -> #{array.to_json}" group.group_unlock_conditions = array.to_json end ensure - UncomputedUnlock.create_all_for_course(@course) + UncomputedUnlock.create_all_for_course(course) end redirect_to manage_unlocks_organization_course_path, notice: 'Successfully set unlock dates.' rescue UnlockSpec::InvalidSyntaxError => e - redirect_to manage_unlocks_organization_course_path(@organization, @course), alert: e.to_s + redirect_to manage_unlocks_organization_course_path(@organization, course), alert: e.to_s end + sig { void } def manage_exercises authorize! :manage_exercises, @course + course = T.must(@course) add_course_breadcrumb add_breadcrumb 'Manage exercises' - @exercises = @course.exercises.natsort_by(&:name) - @exercises_id_map = @exercises.index_by { |e| e.id } + exercises = course.exercises.natsort_by(&:name) + @exercises = T.let(exercises, T.nilable(T::Array[Exercise])) + @exercises_id_map = T.let(exercises.index_by { |e| e.id }, T.nilable(T::Hash[Integer, Exercise])) end + sig { void } def toggle_submission_result_visibility authorize! :toggle_submission_result_visibility, @course - @course.toggle_submission_result_visiblity - redirect_to organization_course_path(@organization, @course), notice: "Exam mode is now #{@course.hide_submission_results ? 'enabled' : 'disabled'}" + course = T.must(@course) + course.toggle_submission_result_visiblity + redirect_to organization_course_path(@organization, course), notice: "Exam mode is now #{course.hide_submission_results ? 'enabled' : 'disabled'}" end private + sig { returns(ActionController::Parameters) } def course_params - if @course.custom? + if T.must(@course).custom? params.require(:course).permit(:title, :description, :material_url, :source_url, :git_branch, :external_scoreboard_url) else params.require(:course).permit(:title, :description, :material_url, :external_scoreboard_url) end end + sig { void } def assign_show_view_vars - @exercises = @course.exercises.includes(:course) - @exercises.preload(:unlocks).where(unlocks: { user: current_user }) - @exercises = @exercises.select { |ex| ex.visible_to?(current_user) } - .natsort_by(&:name) - @exercise_completion_status = ExerciseCompletionStatusGenerator.completion_status(current_user, @course) - @unlocks = @course.unlocked_exercise_names_for(current_user, @exercises) - - if can?(:teach, @course) - last_refresh = @course.course_template.course_template_refreshes.last - @refresh_initialized = last_refresh.status == 'in_progress' || last_refresh.status == 'not_started' if last_refresh + course = T.must(@course) + exercises = course.exercises.includes(:course) + exercises = exercises.select { |ex| ex.visible_to?(current_user) } + .natsort_by(&:name) + @exercises = exercises + @exercise_completion_status = T.let(ExerciseCompletionStatusGenerator.completion_status(current_user, course), T.nilable(T::Hash[Integer, T.nilable(Integer)])) + @unlocks = T.let(course.unlocked_exercise_names_for(current_user, exercises), T.nilable(T::Set[T.nilable(String)])) + + if can?(:teach, course) + last_refresh = T.must(course.course_template).course_template_refreshes.last + @refresh_initialized = T.let(last_refresh.status == 'in_progress' || last_refresh.status == 'not_started', T.nilable(T::Boolean)) if last_refresh end unless current_user.guest? max_submissions = 100 - @submissions = @course.submissions - @submissions = @submissions.where(user_id: current_user.id) unless can? :teach, @course - @submissions = @submissions.order('created_at DESC').includes(:user) - @total_submissions = if can?(:teach, @course) - @course.submissions_count + submissions = course.submissions + submissions = submissions.where(user_id: current_user.id) unless can? :teach, course + submissions = submissions.order(created_at: :desc).includes(:user) + total_submissions = if can?(:teach, course) + course.submissions_count else - @submissions.count + submissions.count end - @submissions = @submissions.limit(max_submissions) - Submission.eager_load_exercises(@submissions) + @total_submissions = T.let(total_submissions, T.nilable(Integer)) + submissions = submissions.limit(max_submissions) + @submissions = T.let(submissions, T.nilable(ActiveRecord::Relation)) + Submission.eager_load_exercises(submissions) end end + sig { void } def set_organization @organization = Organization.find_by!(slug: params[:organization_id]) unauthorized! unless @organization.visibility_allowed?(request, current_user) end + sig { void } def set_course @course = Course.find(params[:id]) end + sig { returns(T.any(ActionController::Parameters, T::Hash[T.untyped, T.untyped])) } def group_params sliced = params.slice(:group, :empty_group) groups = sliced[:group] || {} @@ -238,6 +277,7 @@ def group_params groups end + sig { params(course: Course).returns(CourseTemplateRefresh) } def refresh_course(course) course.refresh(current_user.id) end diff --git a/app/controllers/emails_controller.rb b/app/controllers/emails_controller.rb index 10420239e..1c1f78e9b 100644 --- a/app/controllers/emails_controller.rb +++ b/app/controllers/emails_controller.rb @@ -1,7 +1,10 @@ +# typed: strict # frozen_string_literal: true # Displays the raw list of participant e-mails, useful for mass-mailing scripts. class EmailsController < ApplicationController + extend T::Sig + sig { void } def index organization_id = params[:organization_id] course_id = params[:id] @@ -14,18 +17,21 @@ def index end private + sig { void } def index_global authorize! :view, :emails filter_params = params_starting_with('filter_', :all, remove_prefix: true) filter_params['include_administrators'] = '1' # Always include administrators. Could make this exclude_administrators instead - @emails = User.filter_by(filter_params).order(:email).map(&:email) + emails = User.filter_by(filter_params).order(:email).map(&:email) + @emails = T.let(emails, T.nilable(T::Array[String])) respond_to do |format| format.text do - render plain: @emails.join("\n") + render plain: emails.join("\n") end end end + sig { void } def index_course @course = Course.find(params[:id]) @organization = @course.organization @@ -35,12 +41,13 @@ def index_course add_course_breadcrumb add_breadcrumb('Students', organization_emails_path) - @students = User.course_students(@course) + students = User.course_students(@course) + @students = T.let(students, T.nilable(ActiveRecord::Relation)) respond_to do |format| format.html - format.text { render plain: @students.map { |s| s.email.to_s }.join("\n") } - format.csv { render plain: "Username,Email\n" + @students.map { |s| "#{s.username},#{s.email}" }.join("\n") } + format.text { render plain: students.map { |s| s.email.to_s }.join("\n") } + format.csv { render plain: "Username,Email\n" + students.map { |s| "#{s.username},#{s.email}" }.join("\n") } end end end diff --git a/app/controllers/exercise_status_controller.rb b/app/controllers/exercise_status_controller.rb index d5d87c74a..fa4fef7a8 100644 --- a/app/controllers/exercise_status_controller.rb +++ b/app/controllers/exercise_status_controller.rb @@ -1,23 +1,26 @@ +# typed: strict # frozen_string_literal: true class ExerciseStatusController < ApplicationController skip_authorization_check + sig { void } def show course_id = params[:course_id] user_id = params[:id] course = Course.where(id: course_id).first || Course.where(name: course_id).first - user = User.where(id: user_id).first || User.where(login: user_id).first - + return respond_not_found('Course not found') if course.nil? return respond_forbidden unless course.visible_to?(Guest.new) || current_user.administrator? + user = User.where(id: user_id).first || User.where(login: user_id).first + return respond_not_found('User not found') if user.nil? + user_subs = user.submissions.where(course_id: course.id).to_a.group_by(&:exercise_name) - user_subs.default = [] results = {} course.exercises.each do |ex| - ex.set_submissions_by!(user, user_subs[ex.name]) # used by completed_by? and attempted_by? + ex.set_submissions_by!(user, user_subs.fetch(T.must(ex.name), [])) # used by completed_by? and attempted_by? results[ex.name] = if ex.completed_by?(user) 'completed' elsif ex.attempted_by?(user) diff --git a/app/controllers/exercises_controller.rb b/app/controllers/exercises_controller.rb index d9e634818..c9daeb2e2 100644 --- a/app/controllers/exercises_controller.rb +++ b/app/controllers/exercises_controller.rb @@ -1,11 +1,13 @@ +# typed: strict # frozen_string_literal: true require 'submission_list' class ExercisesController < ApplicationController + sig { void } def show @exercise = Exercise.find(params[:id]) - @course = Course.lock('FOR SHARE').find(@exercise.course_id) + @course = Course.lock('FOR SHARE').find(T.must(@exercise.course_id)) @organization = @course.organization authorize! :read, @course authorize! :read, @exercise @@ -17,18 +19,19 @@ def show Course.transaction(requires_new: true) do if !current_user.guest? - @submissions = @exercise.submissions.order('submissions.created_at DESC') + submissions = @exercise.submissions.order('submissions.created_at DESC') - @submissions = @submissions.where(user_id: current_user.id) unless can? :teach, @course - @submissions = @submissions.includes(:awarded_points).includes(:user).includes(:course) - @submissions = @submissions.limit(50) unless !!params[:view_all] + submissions = submissions.where(user_id: current_user.id) unless can? :teach, @course + submissions = submissions.includes(:awarded_points).includes(:user).includes(:course) + submissions = submissions.limit(50) unless !!params[:view_all] + @submissions = T.let(submissions, T.nilable(ActiveRecord::Relation)) else @submissions = nil end authorize! :read, @submissions - @new_submission = Submission.new + @new_submission = T.let(Submission.new, T.nilable(Submission)) end end format.zip do @@ -60,6 +63,7 @@ def show end end + sig { void } def set_disabled_statuses @course = Course.find(params[:course_id]) @organization = @course.organization @@ -80,6 +84,7 @@ def set_disabled_statuses notice: 'Exercises successfully updated.' end + sig { void } def toggle_submission_result_visibility @exercise = Exercise.find(params[:id]) authorize! :toggle_submission_result_visibility, @exercise.course diff --git a/app/controllers/feedback_answers_charts_controller.rb b/app/controllers/feedback_answers_charts_controller.rb index 43be8e808..71407d3ee 100644 --- a/app/controllers/feedback_answers_charts_controller.rb +++ b/app/controllers/feedback_answers_charts_controller.rb @@ -1,6 +1,8 @@ +# typed: strict # frozen_string_literal: true class FeedbackAnswersChartsController < ApplicationController + sig { void } def show @course = Course.find(params[:course_id]) @organization = @course.organization @@ -25,9 +27,11 @@ def show end private + sig { void } def show_scatterplot - @questions = @course.feedback_questions.select(&:intrange?).sort_by(&:position) - if @questions.size == 2 + questions = T.must(@course).feedback_questions.select(&:intrange?).sort_by(&:position) + @questions = T.let(questions, T.nilable(T::Array[FeedbackQuestion])) + if questions.size == 2 render action: 'show_scatterplot', layout: 'bare' else respond_with_error('Scatterplot only available if there are exactly two numeric questions') diff --git a/app/controllers/feedback_answers_controller.rb b/app/controllers/feedback_answers_controller.rb index 203a8072a..7934abcde 100644 --- a/app/controllers/feedback_answers_controller.rb +++ b/app/controllers/feedback_answers_controller.rb @@ -1,17 +1,21 @@ +# typed: strict # frozen_string_literal: true class FeedbackAnswersController < ApplicationController + extend T::Sig + sig { void } def index if params[:course_id] @course = Course.find(params[:course_id]) - @parent = @course - @numeric_stats = @course.exercises.where(hidden: false).sort.map do |ex| + @parent = T.let(@course, T.nilable(T.any(Course, Exercise))) + numeric_stats = @course.exercises.where(hidden: false).sort.map do |ex| [ex, FeedbackAnswer.numeric_answer_averages(ex), ex.submissions_having_feedback.count] end - @title = @course.title + @numeric_stats = T.let(numeric_stats, T.nilable(T::Array[[Exercise, T::Hash[Integer, Float], Integer]])) + @title = T.let(@course.title, T.nilable(String)) elsif params[:exercise_id] @exercise = Exercise.find(params[:exercise_id]) - @course = @exercise.course + @course = T.must(@exercise.course) @parent = @exercise @numeric_stats = [[@exercise, FeedbackAnswer.numeric_answer_averages(@exercise), @exercise.submissions_having_feedback.count]] @title = @exercise.name @@ -28,7 +32,8 @@ def index add_breadcrumb 'Feedback', organization_course_feedback_answers_path end - @numeric_questions = @course.feedback_questions.where("kind LIKE 'intrange%'").order(:position) + numeric_questions = @course.feedback_questions.where("kind LIKE 'intrange%'").order(:position) + @numeric_questions = T.let(numeric_questions, T.nilable(ActiveRecord::Relation)) respond_to do |format| format.html do @@ -36,20 +41,20 @@ def index authorize! :read_feedback_questions, @parent authorize! :read_feedback_answers, @parent - @text_answers = @parent.feedback_answers + @text_answers = T.let(T.must(@parent).feedback_answers .joins(:feedback_question) .joins(:submission) .joins(submission: :user) # .joins(:exercise) # fails due to :conditions of belongs_to receiving incorrect self :( .where(feedback_questions: { kind: 'text' }) - .order('created_at DESC') - .all + .order(created_at: :desc) + .all, T.nilable(ActiveRecord::Relation)) end format.json do authorize! :read, @parent # We only deliver public statistics so no authorization required render json: { - numeric_questions: @numeric_questions.map do |q| + numeric_questions: numeric_questions.map do |q| { id: q.id, title: q.title, @@ -57,7 +62,7 @@ def index position: q.position } end, - numeric_stats: @numeric_stats.map do |ex, averages, answer_count| + numeric_stats: T.must(@numeric_stats).map do |ex, averages, answer_count| { exercise: { id: ex.id, @@ -73,14 +78,17 @@ def index end end + sig { void } def show - @answer = FeedbackAnswer.find(params[:id]) - authorize! :read, @answer - @course = @answer.course - @exercise = @answer.exercise + answer = FeedbackAnswer.find(params[:id]) + @answer = T.let(answer, T.nilable(FeedbackAnswer)) + authorize! :read, answer + @course = answer.course + @exercise = answer.exercise end # Create multiple answers at once + sig { void } def create submission = Submission.find(params[:submission_id]) authorize! :read, submission diff --git a/app/controllers/feedback_questions_controller.rb b/app/controllers/feedback_questions_controller.rb index b456dd014..7bcc23f78 100644 --- a/app/controllers/feedback_questions_controller.rb +++ b/app/controllers/feedback_questions_controller.rb @@ -1,26 +1,42 @@ +# typed: strict # frozen_string_literal: true # Handles the feedback question editing UI. class FeedbackQuestionsController < ApplicationController + extend T::Sig before_action :set_course before_action :set_organization + sig { void } def index add_course_breadcrumb add_breadcrumb 'Feedback questions' - @questions = @course.feedback_questions.order(:position) + @questions = T.let(T.must(@course).feedback_questions.order(:position), T.nilable(ActiveRecord::Relation)) authorize! :manage_feedback_questions, @course end + sig { void } + def show + @question = FeedbackQuestion.find(params[:id]) + @course = T.must(@question.course) + @organization = @course.organization + authorize! :manage_feedback_questions, @course + add_course_breadcrumb + add_breadcrumb 'Feedback questions', organization_course_feedback_questions_path(@organization, @course) + add_breadcrumb "Question #{@question.title}" + end + + sig { void } def new - @question = FeedbackQuestion.new(course: @course) + @question = T.let(FeedbackQuestion.new(course: @course), T.nilable(FeedbackQuestion)) authorize! :manage_feedback_questions, @course add_course_breadcrumb add_breadcrumb 'Feedback questions', organization_course_feedback_questions_path(@organization, @course) add_breadcrumb 'Add new question' end + sig { void } def create @question = FeedbackQuestion.new(feedback_question_params[:feedback_question]) @question.course = @course @@ -37,19 +53,10 @@ def create end end - def show - @question = FeedbackQuestion.find(params[:id]) - @course = @question.course - @organization = @course.organization - authorize! :manage_feedback_questions, @course - add_course_breadcrumb - add_breadcrumb 'Feedback questions', organization_course_feedback_questions_path(@organization, @course) - add_breadcrumb "Question #{@question.title}" - end - + sig { void } def update @question = FeedbackQuestion.find(params[:id]) - @course = @question.course + @course = T.must(@question.course) @organization = @course.organization authorize! :manage_feedback_questions, @course @@ -65,9 +72,10 @@ def update end end + sig { void } def destroy @question = FeedbackQuestion.find(params[:id]) - @course = @question.course + @course = T.must(@question.course) @organization = @course.organization authorize! :manage_feedback_questions, @course @@ -82,21 +90,25 @@ def destroy end private + sig { returns(ActionController::Parameters) } def feedback_question_params params.permit({ feedback_question: %i[question title kind] }, :intrange_min, :intrange_max, :commit, :course_id) end + sig { void } def set_course @course = Course.find(params[:course_id]) if params[:course_id] authorize! :read, @course end + sig { params(question: FeedbackQuestion).void } def fix_question_kind(question) return unless question.kind == 'intrange' question.kind += "[#{params[:intrange_min]}..#{params[:intrange_max]}]" end + sig { void } def set_organization - @organization = Organization.find_by(slug: params[:organization_id]) + @organization = Organization.find_by!(slug: params[:organization_id]) unless params[:organization_id].nil? end end diff --git a/app/controllers/feedback_replies_controller.rb b/app/controllers/feedback_replies_controller.rb index 5e6da6265..f6ac4be3d 100644 --- a/app/controllers/feedback_replies_controller.rb +++ b/app/controllers/feedback_replies_controller.rb @@ -1,7 +1,9 @@ +# typed: strict # frozen_string_literal: true # Handles replying to a feedback answer by e-mail. class FeedbackRepliesController < ApplicationController + sig { void } def create answer = FeedbackAnswer.find(params['answer_id']) authorize! :reply_feedback_answer, answer @@ -17,7 +19,7 @@ def create if request.referer.present? redirect_to request.referer, params: { notice: 'Reply to a review was mailed' } else - redirect_back fallback_location: root_path + redirect_back_or_to(root_path) end end end diff --git a/app/controllers/files_controller.rb b/app/controllers/files_controller.rb index cc0f5f052..952e2a59d 100644 --- a/app/controllers/files_controller.rb +++ b/app/controllers/files_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true # Shows the files of a submission. @@ -8,6 +9,7 @@ class FilesController < ApplicationController # This exists just to provide comapatability with old files url format # Cannot be redirected in routes, as rack_base_uri cannot be used for redirects. # - Jamox / 12.7.2014 + sig { void } def index redirect_to submission_path(params[:submission_id], anchor: 'files') end diff --git a/app/controllers/full_zip_controller.rb b/app/controllers/full_zip_controller.rb index 8ca87034a..4054ed41f 100644 --- a/app/controllers/full_zip_controller.rb +++ b/app/controllers/full_zip_controller.rb @@ -1,13 +1,16 @@ +# typed: strict # frozen_string_literal: true require 'submission_packager' class FullZipController < ApplicationController + sig { void } def index submission = Submission.find(params[:submission_id]) authorize! :download, submission - exercise = submission.exercise - base_name = "#{submission.user.login}-#{exercise.name}-#{submission.id}" + return respond_not_found("This submission's exercise has been removed") if submission.exercise.nil? + + base_name = "#{T.must(submission.user).login}-#{submission.exercise_name}-#{submission.id}" data = SubmissionPackager.new.get_full_zip(submission) send_data(data, filename: "#{base_name}_full.zip") end diff --git a/app/controllers/migrate_to_other_course_controller.rb b/app/controllers/migrate_to_other_course_controller.rb index 861b8bedd..eaaa73985 100644 --- a/app/controllers/migrate_to_other_course_controller.rb +++ b/app/controllers/migrate_to_other_course_controller.rb @@ -1,21 +1,27 @@ +# typed: strict # frozen_string_literal: true require 'student_submission_migrator' class MigrateToOtherCourseController < ApplicationController + extend T::Sig + sig { void } def show - @old_course = Course.find(params[:course_id]) - @to_course = Course.find(params[:id]) + old_course = Course.find(params[:course_id]) + to_course = Course.find(params[:id]) + @old_course = T.let(old_course, T.nilable(Course)) + @to_course = T.let(to_course, T.nilable(Course)) - authorize! :read, @to_course + authorize! :read, to_course authorize! :read, current_user - @already_migrated = already_migrated - @extra_alert_text = get_extra_text + @already_migrated = T.let(already_migrated, T.nilable(T::Boolean)) + @extra_alert_text = T.let(get_extra_text, T.nilable(String)) return respond_with_error('Please login to continue') if current_user.guest? - respond_with_error('This migration is not allowed') unless StudentSubmissionMigrator.new(@old_course, @to_course, current_user).migration_is_allowed + respond_with_error('This migration is not allowed') unless StudentSubmissionMigrator.new(old_course, to_course, current_user).migration_is_allowed end + sig { void } def migrate @old_course = Course.find(params[:course_id]) @to_course = Course.find(params[:id]) @@ -33,23 +39,26 @@ def migrate end private + sig { returns(T::Boolean) } def check_understanding! - params[:from_course_name].strip == @old_course.name && - params[:to_course_name].strip == @to_course.name && - !!params[:im_sure] && - !already_migrated + return false unless params[:from_course_name].strip == T.must(@old_course).name + return false unless params[:to_course_name].strip == T.must(@to_course).name + return false unless params[:im_sure] + + !already_migrated end + sig { returns(T::Boolean) } def already_migrated - MigratedSubmissions.where(to_course_id: @to_course.id, original_submission_id: current_user.submissions.where(course: @old_course).pluck(:id)).any? + MigratedSubmissions.where(to_course_id: T.must(@to_course).id, original_submission_id: current_user.submissions.where(course: @old_course).pluck(:id)).any? end + sig { returns(T.nilable(String)) } def get_extra_text allowed_migrations = SiteSetting.value(:allow_migrations_between_courses) return nil if allowed_migrations.nil? allowed_migrations.each do |allowed_pair| - puts allowed_pair - return allowed_pair['message'] if allowed_pair['from'] == @old_course.id && allowed_pair['to'] == @to_course.id + return allowed_pair['message'] if allowed_pair['from'] == T.must(@old_course).id && allowed_pair['to'] == T.must(@to_course).id end nil end diff --git a/app/controllers/model_solution_token_useds_controller.rb b/app/controllers/model_solution_token_useds_controller.rb index 28d1aa770..3dd9c734c 100644 --- a/app/controllers/model_solution_token_useds_controller.rb +++ b/app/controllers/model_solution_token_useds_controller.rb @@ -1,25 +1,32 @@ +# typed: strict # frozen_string_literal: true class ModelSolutionTokenUsedsController < ApplicationController - before_action :set_model_solution_token_used, only: [:show, :edit, :update, :destroy] - before_action :only_admins + extend T::Sig + skip_authorization_check + before_action :only_admins! + before_action :set_model_solution_token_used, only: [:show] # GET /model_solution_token_useds + sig { void } def index - @model_solution_token_useds = ModelSolutionTokenUsed.all + @model_solution_token_useds = T.let(ModelSolutionTokenUsed.all, T.nilable(ActiveRecord::Relation)) end # GET /model_solution_token_useds/1 + sig { void } def show end private # Use callbacks to share common setup or constraints between actions. + sig { void } def set_model_solution_token_used - @model_solution_token_used = ModelSolutionTokenUsed.find(params[:id]) + @model_solution_token_used = T.let(ModelSolutionTokenUsed.find(params[:id]), T.nilable(ModelSolutionTokenUsed)) end # Only allow a trusted parameter "white list" through. + sig { returns(ActionController::Parameters) } def model_solution_token_used_params params.require(:model_solution_token_used).permit(:user_id, :course_id, :exercise_name) end diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index 5dd27ff9f..29c379adf 100644 --- a/app/controllers/organizations_controller.rb +++ b/app/controllers/organizations_controller.rb @@ -1,135 +1,161 @@ +# typed: strict # frozen_string_literal: true require 'natsort' class OrganizationsController < ApplicationController - before_action :set_organization, only: %i[show destroy verify disable disable_reason_input toggle_visibility all_courses] + extend T::Sig + before_action :set_organization, only: %i[show verify disable disable_reason_input toggle_visibility all_courses] skip_authorization_check only: %i[index new all_courses] + sig { void } def index ordering = Arel.sql('hidden, LOWER(name)') - @organizations = Organization - .accepted_organizations - .with_attached_logo - .order(ordering) - .reject { |org| org.hidden? && !can?(:view_hidden_organizations, nil) || !org.visibility_allowed?(request, current_user) } - @my_organizations = Organization.taught_organizations(current_user).with_attached_logo.select { |org| org.visibility_allowed?(request, current_user) } - @my_organizations |= Organization.assisted_organizations(current_user).with_attached_logo.select { |org| org.visibility_allowed?(request, current_user) } - @my_organizations |= Organization.participated_organizations(current_user).with_attached_logo.select { |org| org.visibility_allowed?(request, current_user) } - @my_organizations.natsort_by!(&:name) - @courses_under_initial_refresh = Course.where(initial_refresh_ready: false) - @pinned_organizations = Organization - .accepted_organizations - .where(pinned: true) - .with_attached_logo - .order(ordering) - .select { |org| org.visibility_allowed?(request, current_user) } - .reject { |org| org.hidden? && !can?(:view_hidden_organizations, nil) } + organizations = Organization + .accepted_organizations + .with_attached_logo + .order(ordering) + .reject { |org| (org.hidden? && !can?(:view_hidden_organizations, nil)) || !org.visibility_allowed?(request, current_user) } + @organizations = T.let(organizations, T.nilable(T::Array[Organization])) + my_organizations = Organization.taught_organizations(current_user).with_attached_logo.select { |org| org.visibility_allowed?(request, current_user) } + my_organizations |= Organization.assisted_organizations(current_user).with_attached_logo.select { |org| org.visibility_allowed?(request, current_user) } + my_organizations |= Organization.participated_organizations(current_user).with_attached_logo.select { |org| org.visibility_allowed?(request, current_user) } + my_organizations.natsort_by!(&:name) + @my_organizations = T.let(my_organizations, T.nilable(T::Array[Organization])) + @courses_under_initial_refresh = T.let(Course.where(initial_refresh_ready: false), T.nilable(ActiveRecord::Relation)) + pinned_organizations = Organization + .accepted_organizations + .where(pinned: true) + .with_attached_logo + .order(ordering) + .select { |org| org.visibility_allowed?(request, current_user) } + .reject { |org| org.hidden? && !can?(:view_hidden_organizations, nil) } + @pinned_organizations = T.let(pinned_organizations, T.nilable(T::Array[Organization])) render layout: 'landing' end + sig { void } def show add_organization_breadcrumb + organization = T.must(@organization) ordering = Arel.sql('hidden, disabled_status, LOWER(courses.title)') - @my_assisted_courses = Course.assisted_courses(current_user, @organization).order(ordering).select { |c| c.visible_to?(current_user) } - @ongoing_courses = @organization - .courses - .ongoing - .enabled - .where(hidden: false) - .order(ordering) - .select { |c| c.visible_to?(current_user) } - .to_a - if can? :teach, @organization - recently_updated_disabled_courses = @organization.courses + my_assisted_courses = Course.assisted_courses(current_user, organization).order(ordering).select { |c| c.visible_to?(current_user) } + @my_assisted_courses = T.let(my_assisted_courses, T.nilable(T::Array[Course])) + ongoing_courses = organization + .courses + .ongoing + .enabled + .where(hidden: false) + .order(ordering) + .select { |c| c.visible_to?(current_user) } + .to_a + if can? :teach, organization + recently_updated_disabled_courses = organization.courses .ongoing .disabled .where(updated_at: Time.current.all_quarter) .to_a - @ongoing_courses += recently_updated_disabled_courses + ongoing_courses += recently_updated_disabled_courses end - authorize! :read, @ongoing_courses + @ongoing_courses = T.let(ongoing_courses, T.nilable(T::Array[Course])) + authorize! :read, ongoing_courses end + sig { void } def all_courses return respond_forbidden('This feature is only for teachers and administrators.') unless current_user.administrator? || current_user.teacher?(@organization) add_organization_breadcrumb add_breadcrumb 'All Courses' + organization = T.must(@organization) ordering = 'hidden, disabled_status, LOWER(courses.title)' - @my_assisted_courses = Course.assisted_courses(current_user, @organization).order(ordering).select { |c| c.visible_to?(current_user) } - @ongoing_courses = @organization.courses.ongoing.order(ordering).select { |c| c.visible_to?(current_user) } - @expired_courses = @organization.courses.expired.order(ordering).select { |c| c.visible_to?(current_user) } - authorize! :read, @ongoing_courses - authorize! :read, @expired_courses + @my_assisted_courses = Course.assisted_courses(current_user, organization).order(ordering).select { |c| c.visible_to?(current_user) } + ongoing_courses = organization.courses.ongoing.order(ordering).select { |c| c.visible_to?(current_user) } + expired_courses = organization.courses.expired.order(ordering).select { |c| c.visible_to?(current_user) } + @ongoing_courses = ongoing_courses + @expired_courses = T.let(expired_courses, T.nilable(T::Array[Course])) + authorize! :read, ongoing_courses + authorize! :read, expired_courses end + sig { void } def new redirect_to new_setup_organization_path end + sig { void } def list_requests authorize! :view, :unverified_organizations add_breadcrumb 'Unverified organizations' - @unverified_organizations = Organization.pending_organizations + @unverified_organizations = T.let(Organization.pending_organizations, T.nilable(ActiveRecord::Relation)) end + sig { void } def verify authorize! :verify, :unverified_organizations - if !@organization.verified - @organization.verified = true - @organization.verified_at = DateTime.now - @organization.save! - redirect_to organizations_path, notice: "Organization #{@organization.name} is now verified." + organization = T.must(@organization) + if !organization.verified + organization.verified = true + organization.verified_at = Time.current + organization.save! + redirect_to organizations_path, notice: "Organization #{organization.name} is now verified." else - redirect_to organization_path(@organization) + redirect_to organization_path(organization) end end + sig { void } def disable_reason_input authorize! :disable, Organization add_organization_breadcrumb add_breadcrumb 'Disable organization' end + sig { void } def disable authorize! :disable, Organization - if !@organization.disabled - @organization.disabled = true - @organization.disabled_reason = organization_params[:disabled_reason] - @organization.save - redirect_to list_requests_organizations_path, notice: "Organization #{@organization.name} successfully disabled." + organization = T.must(@organization) + if !organization.disabled + organization.disabled = true + organization.disabled_reason = organization_params[:disabled_reason] + organization.save + redirect_to list_requests_organizations_path, notice: "Organization #{organization.name} successfully disabled." else - redirect_to organization_path(@organization) + redirect_to organization_path(organization) end end + sig { void } def toggle_visibility authorize! :toggle_visibility, @organization - @organization.hidden = !@organization.hidden - @organization.save! - redirect_to organization_path, notice: "Organization is now #{@organization.hidden ? 'hidden to users' : 'visible to users'}" + organization = T.must(@organization) + organization.hidden = !organization.hidden + organization.save! + redirect_to organization_path, notice: "Organization is now #{organization.hidden ? 'hidden to users' : 'visible to users'}" end private + sig { params(courses: T.untyped, user: T.untyped).returns(T::Hash[T.untyped, T.untyped]) } def percent_completed_hash(courses, user) percent_completed = {} all_awarded = AwardedPoint.all_awarded(user) all_available = AvailablePoint.courses_points(courses).map(&:course_id) courses.each do |course| - awarded = all_awarded.select { |id| id == course.id }.length.to_f - available = all_available.select { |id| id == course.id }.length.to_f + awarded = all_awarded.count { |id| id == course.id }.to_f + available = all_available.count { |id| id == course.id }.to_f percent_completed[course.id] = 100 * (awarded / available) unless course.hide_submission_results end percent_completed end + sig { void } def set_organization @organization = Organization.find_by!(slug: params[:id]) unauthorized! unless @organization.visibility_allowed?(request, current_user) end + sig { returns(ActionController::Parameters) } def organization_params params.require(:organization).permit(:name, :information, :logo, :slug, :contact_information, :phone, :email, :disabled_reason) end diff --git a/app/controllers/participants_controller.rb b/app/controllers/participants_controller.rb index c4a738ebc..5008b0965 100644 --- a/app/controllers/participants_controller.rb +++ b/app/controllers/participants_controller.rb @@ -1,11 +1,14 @@ +# typed: strict # frozen_string_literal: true require 'csv' require 'natcmp' class ParticipantsController < ApplicationController + extend T::Sig before_action :set_organization, only: [:index] + sig { void } def index if @organization.nil? authorize! :view, :participants_list @@ -19,36 +22,47 @@ def index end add_breadcrumb 'Participants' - @ordinary_fields = %w[username email] - @extra_fields = UserField.all - valid_fields = @ordinary_fields + @extra_fields.map(&:name) + ['include_administrators'] - - @filter_params = params_starting_with('filter_', valid_fields, remove_prefix: true) - @raw_filter_params = params_starting_with('filter_', valid_fields, remove_prefix: false) - - @column_params = params_starting_with('column_', valid_fields, remove_prefix: true) - @raw_column_params = params_starting_with('column_', valid_fields, remove_prefix: false) - @visible_columns = - if @column_params.empty? - @ordinary_fields + @extra_fields.select(&:show_in_participant_list?).map(&:name) + ordinary_fields = %w[username email] + extra_fields = UserField.all + @ordinary_fields = T.let(ordinary_fields, T.nilable(T::Array[String])) + @extra_fields = T.let(extra_fields, T.nilable(T::Array[UserField])) + valid_fields = ordinary_fields + extra_fields.map(&:name) + ['include_administrators'] + + filter_params = params_starting_with('filter_', valid_fields, remove_prefix: true) + @filter_params = T.let(filter_params, T.nilable(T::Hash[String, T.untyped])) + @raw_filter_params = T.let(params_starting_with('filter_', valid_fields, remove_prefix: false), T.nilable(T::Hash[String, T.untyped])) + + column_params = params_starting_with('column_', valid_fields, remove_prefix: true) + @column_params = T.let(column_params, T.nilable(T::Hash[String, T.untyped])) + @raw_column_params = T.let(params_starting_with('column_', valid_fields, remove_prefix: false), T.nilable(T::Hash[String, T.untyped])) + visible_columns = + if column_params.empty? + ordinary_fields + extra_fields.filter_map { |field| field.name if field.show_in_participant_list? } else - @column_params.keys + column_params.keys end + @visible_columns = T.let(visible_columns, T.nilable(T::Array[String])) - @courses = courses.order(:name).to_a + @courses = T.let(courses.order(:name).to_a, T.nilable(T::Array[Course])) + group_completion_course = T.let(nil, T.nilable(Course)) + group_completion = T.let(nil, T.nilable(T::Hash[String, T::Hash[Symbol, T.untyped]])) if params['group_completion_course_id'].present? - @group_completion_course = courses.find(params['group_completion_course_id']) - @group_completion = @group_completion_course.exercise_group_completion_by_user + group_completion_course = courses.find(params['group_completion_course_id']) + group_completion = group_completion_course.exercise_group_completion_by_user end + @group_completion_course = T.let(group_completion_course, T.nilable(Course)) + @group_completion = T.let(group_completion, T.nilable(T::Hash[String, T::Hash[Symbol, T.untyped]])) - @participants = users.filter_by(@filter_params).order(:login) + # filter_by is a User class method, which relations delegate to. + participants = T.unsafe(users).filter_by(filter_params).order(:login) - if @group_completion && params['show_with_no_points'].blank? - @participants = @participants.includes(:awarded_points).to_a.select do |user| - user.awarded_points.to_a.any? { |ap| ap.course_id == @group_completion_course.id } + if group_completion_course && params['show_with_no_points'].blank? + participants = participants.includes(:awarded_points).to_a.select do |user| + user.awarded_points.to_a.any? { |ap| ap.course_id == group_completion_course.id } end end + @participants = T.let(participants, T.nilable(T.any(ActiveRecord::Relation, T::Array[User]))) respond_to do |format| format.html @@ -61,79 +75,88 @@ def index end end + sig { void } def show - @user = User.find(params[:id]) - authorize! :view_participant_information, @user + user = User.find(params[:id]) + @user = T.let(user, T.nilable(User)) + authorize! :view_participant_information, user # TODO: bit ugly -- and now it's even worse! - @awarded_points = - AwardedPoint.where(id: AwardedPoint.all_awarded(@user)) + awarded_points = + AwardedPoint.where(id: AwardedPoint.all_awarded(user)) .to_a .sort! .group_by(&:course_id).transform_values do |course_points| - { - awarded: course_points.reject(&:awarded_after_soft_deadline?).map(&:name), - late: course_points.select(&:awarded_after_soft_deadline?).map(&:name) - } + late, on_time = course_points.partition(&:awarded_after_soft_deadline?) + { awarded: on_time.map(&:name), late: late.map(&:name) } end - + @awarded_points = T.let(awarded_points, T.nilable(T::Hash[T.nilable(Integer), { awarded: T::Array[String], late: T::Array[String] }])) if current_user.administrator? add_breadcrumb 'Participants', :participants_path - add_breadcrumb @user.username, participant_path(@user) - @app_data = JSON.pretty_generate(JSON.parse(@user.user_app_data.to_json)) - @courses_mooc_fi_status = @user.courses_mooc_fi_migration_status - @courses_mooc_fi_status_label = courses_mooc_fi_status_label(@user, @courses_mooc_fi_status) - @courses_mooc_fi_force_migrate_available = !@user.managed_externally? || courses_mooc_fi_account_missing?(@courses_mooc_fi_status) + add_breadcrumb user.username, participant_path(user) + @app_data = T.let(JSON.pretty_generate(JSON.parse(user.user_app_data.to_json)), T.nilable(String)) + courses_mooc_fi_status = user.courses_mooc_fi_migration_status + @courses_mooc_fi_status = T.let(courses_mooc_fi_status, T.nilable(T::Hash[Symbol, T.untyped])) + @courses_mooc_fi_status_label = T.let(courses_mooc_fi_status_label(user, courses_mooc_fi_status), T.nilable(String)) + @courses_mooc_fi_force_migrate_available = T.let(!user.managed_externally? || courses_mooc_fi_account_missing?(courses_mooc_fi_status), T.nilable(T::Boolean)) else - add_breadcrumb 'My stats', participant_path(@user) + add_breadcrumb 'My stats', participant_path(user) end - @courses = [] - @missing_points = {} - @percent_completed = {} - @group_completion_counts = {} - @group_available_points = {} - for course_id in @awarded_points.keys + courses = T.let([], T::Array[Course]) + missing_points = T.let({}, T::Hash[T.nilable(Integer), T::Array[String]]) + percent_completed = T.let({}, T::Hash[T.nilable(Integer), Numeric]) + group_completion_counts = T.let({}, T::Hash[T.nilable(Integer), T::Hash[String, T::Hash[Symbol, Numeric]]]) + @courses = courses + @missing_points = T.let(missing_points, T.nilable(T::Hash[T.nilable(Integer), T::Array[String]])) + @percent_completed = T.let(percent_completed, T.nilable(T::Hash[T.nilable(Integer), Numeric])) + @group_completion_counts = T.let(group_completion_counts, T.nilable(T::Hash[T.nilable(Integer), T::Hash[String, T::Hash[Symbol, Numeric]]])) + @group_available_points = T.let({}, T.nilable(T::Hash[T.untyped, T.untyped])) + for course_id in awarded_points.keys course = Course.find(course_id) next if course.hide_submissions? - @courses << course + courses << course - awarded = @awarded_points[course.id] + awarded = T.must(awarded_points[course.id]) missing = AvailablePoint.course_points(course).order!.map(&:name) - awarded[:awarded] - awarded[:late] - @missing_points[course_id] = missing + missing_points[course_id] = missing - @percent_completed[course_id] = if awarded[:awarded].size + awarded[:late].size + missing.size > 0 - 100 * ((awarded[:awarded].size.to_f + awarded[:late].size.to_f * course.soft_deadline_point_multiplier) / (awarded[:awarded].size + awarded[:late].size + missing.size)) + percent_completed[course_id] = if awarded[:awarded].size + awarded[:late].size + missing.size > 0 + 100 * ((awarded[:awarded].size.to_f + (awarded[:late].size.to_f * course.soft_deadline_point_multiplier)) / (awarded[:awarded].size + awarded[:late].size + missing.size)) else 0 end - @group_completion_counts[course_id] = course.exercise_group_completion_counts_for_user(@user) + group_completion_counts[course_id] = course.exercise_group_completion_counts_for_user(user) end - @submissions = if current_user.administrator? || current_user.id == @user.id - @user.submissions.order('created_at DESC').includes(:user).includes(:course) + submissions = if current_user.administrator? || current_user.id == user.id + user.submissions.order(created_at: :desc).includes(:user).includes(:course) else # teacher and assistant sees only submissions for own teacherd courses - @user.submissions.order('created_at DESC').includes(:user, :course).where(course: current_user.teaching_in_courses) + user.submissions.order(created_at: :desc).includes(:user, :course).where(course: current_user.teaching_in_courses) end - @submission_count = @submissions.count - @submissions = @submissions.limit(100) unless !!params[:view_all] + @submission_count = T.let(submissions.count, T.nilable(Integer)) + submissions = submissions.limit(100) unless !!params[:view_all] + @submissions = T.let(submissions, T.nilable(ActiveRecord::Relation)) - Submission.eager_load_exercises(@submissions) + Submission.eager_load_exercises(submissions) end + sig { void } def me authorize! :view_participant_information, current_user redirect_to participant_path(current_user) end + sig { void } def password_reset_link @user = User.find(params[:id]) authorize! :view_participant_information, @user return respond_forbidden('This feature is disabled for admin accounts') if @user.administrator? - @password_reset_link = @user.generate_password_reset_link + @password_reset_link = T.let(@user.generate_password_reset_link, T.nilable(String)) end + sig { void } def force_migrate_to_courses_mooc_fi @user = User.find(params[:id]) authorize! :view_participant_information, @user @@ -157,10 +180,12 @@ def force_migrate_to_courses_mooc_fi private # nil means the live status is unknown (unreachable/unconfigured) -- trust the local flag # instead of treating the account as missing. + sig { params(status: T.nilable(T::Hash[Symbol, T.untyped])).returns(T::Boolean) } def courses_mooc_fi_account_missing?(status) status.present? && (!status[:shadow_user_exists] || status[:deleted_at].present?) end + sig { params(user: User, status: T.nilable(T::Hash[Symbol, T.untyped])).returns(String) } def courses_mooc_fi_status_label(user, status) return 'Broken: flagged as migrated locally but missing the target id' if user.externally_managed_without_target? if user.managed_externally? @@ -171,12 +196,14 @@ def courses_mooc_fi_status_label(user, status) 'Not migrated' end + sig { returns(T::Hash[Symbol, T.untyped]) } def index_json_data + visible_columns = T.must(@visible_columns) result = [] - @participants.each do |user| - record = { id: user.id, username: user.login, email: user.email } - @extra_fields.each do |field| - if @visible_columns.include?(field.name) + T.must(@participants).each do |user| + record = T.let({ id: user.id, username: user.login, email: user.email }, T::Hash[T.any(Symbol, String), T.untyped]) + T.must(@extra_fields).each do |field| + if visible_columns.include?(field.name) record[field.name] = user.field_ruby_value(field) end end @@ -200,31 +227,37 @@ def index_json_data } end + sig { returns(String) } def index_csv + ordinary_fields = T.must(@ordinary_fields) + extra_fields = T.must(@extra_fields) + visible_columns = T.must(@visible_columns) + group_completion = @group_completion CSV.generate(force_quotes: true) do |csv| - title_row = (@ordinary_fields + @extra_fields.map(&:name)).select { |f| @visible_columns.include?(f) }.map(&:humanize) + title_row = (ordinary_fields + extra_fields.map(&:name)).filter_map { |f| f.humanize if visible_columns.include?(f) } - if @group_completion - completion_cols = @group_completion.keys.sort { |a, b| Natcmp.natcmp(a, b) } + completion_cols = T.let([], T::Array[String]) + if group_completion + completion_cols = group_completion.keys.sort { |a, b| Natcmp.natcmp(a, b) } title_row += completion_cols end csv << title_row - @participants.each do |user| + T.must(@participants).each do |user| row = [] - for field in @ordinary_fields - row << user.send(field) if @visible_columns.include?(field) + for field in ordinary_fields + row << user.send(field) if visible_columns.include?(field) end - for field in @extra_fields - if @visible_columns.include?(field.name) + for field in extra_fields + if visible_columns.include?(field.name) row << user.field_ruby_value(field) end end - if @group_completion + if group_completion for group in completion_cols - group_data = @group_completion[group] + group_data = T.must(group_completion[group]) points = group_data[:points_by_user][user.id] || 0 total = group_data[:available_points] percentage = format('%.3f%%', (points.to_f / total.to_f) * 100) @@ -237,8 +270,8 @@ def index_csv end end - private + sig { void } def set_organization - @organization = Organization.find_by(slug: params[:organization_id]) unless params[:organization_id].nil? + @organization = Organization.find_by!(slug: params[:organization_id]) unless params[:organization_id].nil? end end diff --git a/app/controllers/password_reset_keys_controller.rb b/app/controllers/password_reset_keys_controller.rb index 53ff0b359..2d954fbbb 100644 --- a/app/controllers/password_reset_keys_controller.rb +++ b/app/controllers/password_reset_keys_controller.rb @@ -1,31 +1,38 @@ +# typed: strict # frozen_string_literal: true class PasswordResetKeysController < ApplicationController + extend T::Sig skip_authorization_check add_breadcrumb 'Forgot password', ->(*_a) { }, only: %i[new show] + sig { void } + def show + find_key_and_user + end + + sig { void } def new; end + sig { void } def create - @email = params['email'].to_s.strip - if @email.empty? + email = params['email'].to_s.strip + @email = T.let(email, T.nilable(String)) + if email.empty? return redirect_to(new_password_reset_key_path, alert: 'No e-mail address provided') end - user = User.find_by('lower(email) = ?', @email.downcase) + user = User.find_by('lower(email) = ?', email.downcase) unless user return redirect_to(new_password_reset_key_path, alert: 'No such e-mail address registered') end key = ActionToken.generate_password_reset_key_for(user) - PasswordResetKeyMailer.reset_link_email(user, key).deliver - end - - def show - find_key_and_user + PasswordResetKeyMailer.reset_link_email(user, key).deliver_now end + sig { void } def destroy find_key_and_user @@ -39,33 +46,35 @@ def destroy return render action: :show, status: :forbidden end - if @user.managed_externally? - success = @user.update_password_via_courses_mooc_fi(nil, params[:password]) + user = T.must(@user) + key = T.must(@key) + if user.managed_externally? + success = user.update_password_via_courses_mooc_fi(nil, params[:password]) if success - @key.destroy + key.destroy flash[:success] = 'Your password has been reset.' redirect_to root_path else flash.now[:alert] = 'Failed to reset password.' render action: :show, status: :forbidden end - elsif @user.externally_managed_without_target? + elsif user.externally_managed_without_target? # Flagged as managed by courses.mooc.fi but with no id to delegate to. Do not fall back to a # local reset (authentication would ignore it); surface the misconfiguration instead. - Rails.logger.error("Password reset for user #{@user.id}: managed by courses.mooc.fi but missing courses_mooc_fi_user_id") + Rails.logger.error("Password reset for user #{user.id}: managed by courses.mooc.fi but missing courses_mooc_fi_user_id") flash.now[:alert] = 'This account cannot be reset right now. Please contact support.' render action: :show, status: :forbidden else - @user.password = params[:password] - if @user.save + user.password = params[:password] + if user.save # Not yet managed by courses.mooc.fi: migrate the user there with the new password - @user.post_new_user_to_courses_mooc_fi(params[:password]) - @key.destroy + user.post_new_user_to_courses_mooc_fi(params[:password]) + key.destroy flash[:success] = 'Your password has been reset.' redirect_to root_path else - flash.now[:alert] = if @user.errors[:password] - 'Password ' + @user.errors[:password].join(', ') + flash.now[:alert] = if user.errors[:password].any? + 'Password ' + user.errors[:password].join(', ') else 'Failed to set password' end @@ -75,10 +84,12 @@ def destroy end private + sig { void } def find_key_and_user token = params['token'] - @key = ActionToken.find_by(token: token) - raise ActiveRecord::RecordNotFound, 'Invalid password reset key' if @key.nil? || @key.expired? - @user = @key.user + key = ActionToken.find_by(token: token) + @key = T.let(key, T.nilable(ActionToken)) + raise ActiveRecord::RecordNotFound, 'Invalid password reset key' if key.nil? || key.expired? + @user = T.let(key.user, T.nilable(User)) end end diff --git a/app/controllers/points_controller.rb b/app/controllers/points_controller.rb index 2d973091c..5faea6829 100644 --- a/app/controllers/points_controller.rb +++ b/app/controllers/points_controller.rb @@ -1,11 +1,14 @@ +# typed: strict # frozen_string_literal: true require 'natsort' # Shows the points summary table and exercise group-specific tables. class PointsController < ApplicationController + extend T::Sig include PointsHelper before_action :set_organization + sig { void } def index @course = Course.find(params[:course_id]) authorize! :see_points, @course @@ -14,7 +17,7 @@ def index add_breadcrumb 'Points' if can?(:teach, @course) - @user_fields = UserField.all.select(&:show_in_participant_list?) + @user_fields = T.let(UserField.all.select(&:show_in_participant_list?), T.nilable(T::Array[UserField])) end only_for_user = User.find_by(login: params[:username]) @@ -23,19 +26,19 @@ def index if only_for_user exercises = @course.exercises.enabled.where(exercises: { hidden: false, hide_submission_results: false }) sheets = @course.gdocs_sheets(exercises).natsort - @summary = summary_hash(@course, exercises, sheets, only_for_user) - sort_summary(@summary, params[:sort_by]) if params[:sort_by] - @summary + summary = summary_hash(@course, exercises, sheets, only_for_user) + @summary = T.let(summary, T.nilable(T::Hash[Symbol, T.untyped])) + sort_summary(summary, params[:sort_by]) if params[:sort_by] else - @summary = Rails.cache.fetch("points_#{@course.id}_admin_#{current_user.administrator?}/", expires_in: 1.minute) do - exercises = @course.exercises.enabled.where(exercises: { hidden: false }) - exercises = exercises.where(hide_submission_results: false) unless current_user.administrator? - sheets = @course.gdocs_sheets(exercises).natsort - @summary = summary_hash(@course, exercises, sheets) - - @summary + course = @course + summary = Rails.cache.fetch("points_#{course.id}_admin_#{current_user.administrator?}/", expires_in: 1.minute) do + visible_exercises = course.exercises.enabled.where(exercises: { hidden: false }) + visible_exercises = visible_exercises.where(hide_submission_results: false) unless current_user.administrator? + visible_sheets = course.gdocs_sheets(visible_exercises).natsort + summary_hash(course, visible_exercises, visible_sheets) end - sort_summary(@summary, params[:sort_by]) if params[:sort_by] + @summary = summary + sort_summary(summary, params[:sort_by]) if params[:sort_by] end respond_to do |format| @@ -47,8 +50,10 @@ def index end end + sig { void } def show - @sheetname = params[:id] + sheetname = T.let(params[:id], String) + @sheetname = T.let(sheetname, T.nilable(String)) @course = Course.find(params[:course_id]) authorize! :see_points, @course return respond_unauthorized('Authentication required') if current_user.guest? @@ -59,20 +64,23 @@ def show add_course_breadcrumb add_breadcrumb 'Points', organization_course_points_path(@organization, @course) - add_breadcrumb @sheetname + add_breadcrumb sheetname - @exercises = Exercise.course_gdocs_sheet_exercises(@course, @sheetname, current_user.administrator?).includes(:available_points).order!(:name) - @users_to_points = AwardedPoint.per_user_in_course_with_sheet(@course, @sheetname, hidden: current_user.administrator?) + exercises = Exercise.course_gdocs_sheet_exercises(@course, sheetname, current_user.administrator?).includes(:available_points).order!(:name) + @exercises = T.let(exercises, T.nilable(ActiveRecord::Relation)) + users_to_points = AwardedPoint.per_user_in_course_with_sheet(@course, sheetname, hidden: current_user.administrator?) + @users_to_points = T.let(users_to_points, T.nilable(T::Hash[String, T::Array[String]])) - @users = @course.users.includes(:user_field_values) + users = @course.users.includes(:user_field_values) if params[:sort_by] == 'points' - @users = @users.sort_by do |u| - [-@users_to_points[u.login].size, u.login.downcase] + users = users.sort_by do |u| + [-users_to_points.fetch(u.login, []).size, u.login.downcase] end else - @users.order! + users.order! end - @users = [current_user] unless can?(:teach, @course) + users = [current_user] unless can?(:teach, @course) + @users = T.let(users, T.nilable(T.any(ActiveRecord::Relation, T::Array[User]))) respond_to do |format| format.html format.json do @@ -86,17 +94,18 @@ def show end private + sig { params(course: Course, visible_exercises: T::Enumerable[Exercise], sheets: T::Array[String], only_for_user: T.nilable(User)).returns(T::Hash[Symbol, T.untyped]) } def summary_hash(course, visible_exercises, sheets, only_for_user = nil) per_user_and_sheet = AwardedPoint.count_per_user_in_course_with_sheet(course, sheets, only_for_user, current_user.administrator?) user_totals = {} for username, per_sheet in per_user_and_sheet user_totals[username] ||= 0 - user_totals[username] += per_sheet.values.reduce(0, &:+) + user_totals[username] += per_sheet.values.sum end include_admins = current_user.administrator? - users = User.select('login, email, users.id, administrator').where(login: per_user_and_sheet.keys.sort_by(&:downcase)).includes(:organizations).includes(:user_field_values).order('login ASC') + users = User.select('login, email, users.id, administrator').where(login: per_user_and_sheet.keys.sort_by(&:downcase)).includes(:organizations).includes(:user_field_values).order(:login) users = users.where(administrator: false) unless include_admins @@ -118,6 +127,7 @@ def summary_hash(course, visible_exercises, sheets, only_for_user = nil) } end + sig { params(summary: T::Hash[Symbol, T.untyped], sorting: String).void } def sort_summary(summary, sorting) if sorting == 'total_points' summary[:users] = summary[:users].sort_by { |user| [-summary[:total_for_user][user.login].to_i, user.login] } @@ -127,7 +137,8 @@ def sort_summary(summary, sorting) end end + sig { void } def set_organization - @organization = Organization.find_by(slug: params[:organization_id]) + @organization = Organization.find_by!(slug: params[:organization_id]) end end diff --git a/app/controllers/positions_controller.rb b/app/controllers/positions_controller.rb index 3bfc331c4..c67b47d4b 100644 --- a/app/controllers/positions_controller.rb +++ b/app/controllers/positions_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true # Handles feedback questions reordering @@ -5,11 +6,12 @@ # This is poorly named. Also, as noted in FeedbackQuestionsController, # the feedback question editor probably shouldn't exist. class PositionsController < ApplicationController + sig { void } def update if params[:feedback_question_id] record = FeedbackQuestion.find(params[:feedback_question_id]) authorize! :manage_feedback_questions, record.course - redirect_dest = organization_course_feedback_questions_path(record.course.organization, record.course) + redirect_dest = organization_course_feedback_questions_path(T.must(record.course).organization, record.course) else return respond_not_found('Unknown resource to move') end diff --git a/app/controllers/results_controller.rb b/app/controllers/results_controller.rb index e11391560..6327374e4 100644 --- a/app/controllers/results_controller.rb +++ b/app/controllers/results_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require 'test_run_grader' @@ -7,6 +8,7 @@ class ResultsController < ApplicationController skip_authorization_check + sig { void } def create submission = Submission.find(params[:submission_id]) @@ -17,7 +19,7 @@ def create end SandboxResultsSaver.save_results(submission, filtered_params) - Rails.cache.delete("api_v8_core_submission_show_#{submission.id}_user_#{submission.user.id}") + Rails.cache.delete("api_v8_core_submission_show_#{submission.id}_user_#{submission.user_id}") rescue SandboxResultsSaver::InvalidTokenError respond_unauthorized('Invalid or expired token') else diff --git a/app/controllers/reviewed_submissions_controller.rb b/app/controllers/reviewed_submissions_controller.rb index 69310984a..7a1f01230 100644 --- a/app/controllers/reviewed_submissions_controller.rb +++ b/app/controllers/reviewed_submissions_controller.rb @@ -1,7 +1,9 @@ +# typed: strict # frozen_string_literal: true # Shows for admin the list of submissions that have already been reviewed. class ReviewedSubmissionsController < ApplicationController + sig { void } def index @course = Course.find(params[:course_id]) @organization = @course.organization @@ -10,10 +12,11 @@ def index add_breadcrumb 'Code reviews', organization_course_reviews_path(@organization, @course) add_breadcrumb 'Reviewed submissions' - @submissions = @course.submissions - .where(reviewed: true) - .includes(reviews: :reviewer) - .includes(:user) - .order('created_at DESC') + submissions = @course.submissions + .where(reviewed: true) + .includes(reviews: :reviewer) + .includes(:user) + .order(created_at: :desc) + @submissions = T.let(submissions, T.nilable(ActiveRecord::Relation)) end end diff --git a/app/controllers/reviews_controller.rb b/app/controllers/reviews_controller.rb index 27ba84ab5..5e3768bbd 100644 --- a/app/controllers/reviews_controller.rb +++ b/app/controllers/reviews_controller.rb @@ -1,19 +1,24 @@ +# typed: strict # frozen_string_literal: true require 'natsort' # Presents the code review UI. class ReviewsController < ApplicationController + extend T::Sig before_action :set_organization, except: %i[new create] + sig { void } def index if params[:course_id] fetch :course - @organization = @course.organization - @my_reviews = @course.submissions - .where(user_id: current_user.id) - .where('requests_review OR requires_review OR reviewed') - .order('created_at DESC') + course = T.must(@course) + @organization = course.organization + my_reviews = course.submissions + .where(user_id: current_user.id) + .where('requests_review OR requires_review OR reviewed') + .order(created_at: :desc) + @my_reviews = T.let(my_reviews, T.nilable(ActiveRecord::Relation)) respond_to do |format| format.html do @@ -27,58 +32,67 @@ def index end else fetch :submission, :files - raise "Submission's exercise has been moved or deleted" unless @submission.exercise + submission = T.must(@submission) + return respond_not_found("This submission's exercise has been removed") unless submission.exercise - @course = @submission.course - @organization = @course.organization + course = T.must(submission.course) + @course = course + @organization = course.organization respond_to do |format| format.html do add_course_breadcrumb add_exercise_breadcrumb add_submission_breadcrumb - breadcrumb_label = @submission.reviews.count == 1 ? 'Code review' : 'Code reviews' - add_breadcrumb breadcrumb_label, submission_reviews_path(@submission) + breadcrumb_label = submission.reviews.count == 1 ? 'Code review' : 'Code reviews' + add_breadcrumb breadcrumb_label, submission_reviews_path(submission) render 'reviews/submission_index' end end end end + sig { void } def new fetch :submission, :files + submission = T.must(@submission) + return respond_not_found("This submission's exercise has been removed") unless submission.exercise - @show_page_presence = true + @show_page_presence = T.let(true, T.nilable(T::Boolean)) - @course = @submission.course - @organization = @course.organization + course = T.must(submission.course) + @course = course + @organization = course.organization add_course_breadcrumb - add_breadcrumb 'Code reviews', organization_course_reviews_path(@organization, @course) + add_breadcrumb 'Code reviews', organization_course_reviews_path(@organization, course) add_breadcrumb 'Code review editor' - @new_review = Review.new( - submission_id: @submission.id, + @new_review = T.let(Review.new( + submission_id: submission.id, reviewer_id: current_user.id - ) - authorize! :create_review, @course + ), T.nilable(Review)) + authorize! :create_review, course render 'reviews/submission_index' end + sig { void } def create fetch :submission - @review = Review.new( - submission_id: @submission.id, + submission = T.must(@submission) + review = Review.new( + submission_id: submission.id, reviewer_id: current_user.id, review_body: params[:review][:review_body] ) - authorize! :create_review, @submission.course + @review = review + authorize! :create_review, submission.course begin ActiveRecord::Base.connection.transaction do award_points mark_as_reviewed - @review.submission.save! - @review.save! + T.must(review.submission).save! + review.save! end rescue StandardError ::Rails.logger.error($!) @@ -86,12 +100,14 @@ def create else flash[:success] = 'Code review added.' send_email_about_new_review if params[:send_email] - @course = @submission.course - @organization = @course.organization - redirect_to organization_course_reviews_path(@organization, @course) + course = T.must(submission.course) + @course = course + @organization = course.organization + redirect_to organization_course_reviews_path(@organization, course) end end + sig { void } def update fetch :review authorize! :update, @review @@ -104,21 +120,24 @@ def update end end + sig { void } def destroy fetch :review authorize! :delete, @review - if @review.destroy + review = T.must(@review) + if review.destroy flash[:success] = 'Code review deleted.' - redirect_to new_submission_review_path(@review.submission_id) + redirect_to new_submission_review_path(review.submission_id) else respond_with_error('Failed to delete code review.') end end private + sig { returns(T::Hash[Symbol, T.untyped]) } def course_reviews_json - submissions = @my_reviews.includes(reviews: %i[reviewer submission]) - exercises = @course.exercises.index_by { |e| e.name } + submissions = T.must(@my_reviews).includes(reviews: %i[reviewer submission]) + exercises = T.must(@course).exercises.index_by { |e| e.name } reviews = submissions.map do |s| s.reviews.map do |r| { @@ -133,13 +152,15 @@ def course_reviews_json } end + sig { params(exercises: T::Hash[T.nilable(String), Exercise], review: Review).returns(T::Hash[Symbol, T.untyped]) } def review_json(exercises, review) - available_points = exercises[review.submission.exercise_name].available_points.where(requires_review: true).map(&:name) + exercise = exercises[T.must(review.submission).exercise_name] + available_points = exercise ? exercise.available_points.where(requires_review: true).map(&:name) : [] points_not_awarded = available_points - review.points_list { id: review.id, marked_as_read: review.marked_as_read, - reviewer_name: review.reviewer.display_name, + reviewer_name: review.reviewer&.display_name || '(deleted)', review_body: review.review_body, points: review.points_list.natsort, points_not_awarded: points_not_awarded.natsort, @@ -150,18 +171,20 @@ def review_json(exercises, review) } end + sig { params(read: T::Boolean).void } def mark_as_read(read) which = read ? 'read' : 'unread' fetch :review authorize! (read ? :mark_as_read : :mark_as_unread), @review + review = T.must(@review) - @review.marked_as_read = read - if @review.save + review.marked_as_read = read + if review.save respond_to do |format| format.html do flash[:success] = "Code review marked as #{which}." - redirect_to submission_reviews_path(@review.submission) + redirect_to submission_reviews_path(review.submission) end format.json do render json: { status: 'OK' } @@ -172,35 +195,39 @@ def mark_as_read(read) end end + sig { void } def update_review fetch :review authorize! :update, @review - @review.review_body = params[:review][:review_body] + review = T.must(@review) + review.review_body = params[:review][:review_body] begin mark_as_reviewed award_points - @review.submission.save! - @review.save! + T.must(review.submission).save! + review.save! rescue StandardError ::Rails.logger.error($!) respond_with_error('Failed to save code review.') else flash[:success] = 'Code review edited. (No notification sent).' - redirect_to new_submission_review_path(@review.submission_id) + redirect_to new_submission_review_path(review.submission_id) end end + sig { void } def mark_as_reviewed - sub = @review.submission + sub = T.must(T.must(@review).submission) sub.reviewed = true sub.review_dismissed = false sub.of_same_kind .where('(requires_review OR requests_review) AND NOT reviewed') - .where(['created_at < ?', sub.created_at]) + .where(created_at: ...sub.created_at) .update_all(newer_submission_reviewed: true) end + sig { params(stuff: Symbol).void } def fetch(*stuff) if stuff.include? :course @course = Course.find(params[:course_id]) @@ -211,21 +238,24 @@ def fetch(*stuff) authorize! :read, @submission end if stuff.include? :review - @review = Review.find(params[:id]) + @review = T.let(Review.find(params[:id]), T.nilable(Review)) authorize! :read, @review end - @files = SourceFileList.for_submission(@submission) if stuff.include? :files + @files = T.let(SourceFileList.for_submission(T.must(@submission)), T.nilable(SourceFileList)) if stuff.include? :files end + sig { void } def send_email_about_new_review - ReviewMailer.review_email(@review).deliver + ReviewMailer.review_email(T.must(@review)).deliver_now end + sig { void } def award_points - submission = @review.submission + review = T.must(@review) + submission = T.must(review.submission) exercise = submission.exercise - course = exercise.course raise 'Exercise of submission has been moved or deleted' unless exercise + course = T.must(exercise.course) available_points = exercise.available_points.where(requires_review: true).map(&:name) previous_points = course.awarded_points.where(user_id: submission.user_id, name: available_points).map(&:name) @@ -248,12 +278,12 @@ def award_points end end - @review.points = (@review.points_list + new_points + previous_points).uniq.natsort.join(' ') + review.points = (review.points_list + new_points + previous_points).uniq.natsort.join(' ') submission.points = (submission.points_list + new_points + previous_points).uniq.natsort.join(' ') end - private + sig { void } def set_organization - @organization = Organization.find_by(slug: params[:organization_id]) + @organization = Organization.find_by!(slug: params[:organization_id]) unless params[:organization_id].nil? end end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 57d2b3034..13e0a93c5 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -1,28 +1,32 @@ +# typed: strict # frozen_string_literal: true # Handles login and logout. class SessionsController < ApplicationController skip_authorization_check + sig { void } def new - session[:return_to] ||= params[:return_to] + session[:return_to] ||= url_from(params[:return_to]) if signed_in? path = session[:return_to] ||= root_path redirect_to path end end + sig { void } def create begin clear_expired_sessions rescue StandardError + # Cleanup is best effort; it must not block logging in. end user, status = User.authenticate_with_status(params[:session][:login], params[:session][:password]) case status when :accepted - sign_in user + sign_in T.must(user) try_to_redirect_back when :unavailable try_to_redirect_incorrect_login(alert: 'Login is temporarily unavailable. Please try again shortly.') @@ -33,12 +37,14 @@ def create end end + sig { void } def destroy sign_out try_to_redirect_back(notice: 'Goodbye') end private + sig { params(redirect_params: T::Hash[Symbol, T.untyped]).void } def try_to_redirect_back(redirect_params = {}) if session[:return_to].present? return_to = session.delete(:return_to) @@ -52,6 +58,7 @@ def try_to_redirect_back(redirect_params = {}) end end + sig { params(redirect_params: T::Hash[Symbol, T.untyped]).void } def try_to_redirect_incorrect_login(redirect_params = {}) if request.referer.present? redirect_to request.referer, redirect_params @@ -62,6 +69,7 @@ def try_to_redirect_incorrect_login(redirect_params = {}) end end + sig { void } def clear_expired_sessions Session.delete_expired end diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb index c874603f0..c8fa5ac1c 100644 --- a/app/controllers/settings_controller.rb +++ b/app/controllers/settings_controller.rb @@ -1,33 +1,39 @@ +# typed: strict # frozen_string_literal: true class SettingsController < ApplicationController + extend T::Sig skip_authorization_check before_action :set_user + sig { void } def show authorize! :read, @user end + sig { void } def update authorize! :update, @user + user = T.must(@user) User.transaction do - @email_before = @user.email + email_before = user.email + @email_before = T.let(email_before, T.nilable(String)) set_email - password_changed = maybe_update_password(@user, params[:user]) + password_changed = maybe_update_password(user, params[:user]) set_user_fields - if @user.errors.empty? && @user.save + if user.errors.empty? && user.save # Password changed locally: migrate the user to courses.mooc.fi - @user.post_new_user_to_courses_mooc_fi(params[:user][:password]) if password_changed && !@user.managed_externally? - RecentlyChangedUserDetail.email_changed.create!(old_value: @email_before, new_value: @user.email, username: @user.login, user_id: @user.id) unless @email_before.casecmp(@user.email).zero? + user.post_new_user_to_courses_mooc_fi(params[:user][:password]) if password_changed && !user.managed_externally? + RecentlyChangedUserDetail.email_changed.create!(old_value: email_before, new_value: user.email, username: user.login, user_id: user.id) unless T.must(email_before.casecmp(user.email)).zero? flash[:notice] = if password_changed 'Changes saved and password changed' else 'Changes saved' end - redirect_to participant_settings_path(@user) + redirect_to participant_settings_path(user) else flash.now[:error] = 'Failed to save profile' render action: :show, status: :forbidden @@ -35,6 +41,7 @@ def update end end + sig { void } def dangerously_destroy_user @user = authenticate_current_user_destroy if @user.submissions.length != 0 @@ -54,39 +61,46 @@ def dangerously_destroy_user redirect_to root_url, notice: "The account #{username} (#{email}) has been permanently destroyed." end + sig { void } def verify_dangerously_destroying_user @user = authenticate_current_user_destroy end + sig { void } def user_has_submissions @user = authenticate_current_user_destroy end private + sig { returns(User) } def authenticate_current_user_destroy user = User.find(params[:participant_id]) authorize! :destroy, user user end + sig { void } def set_user - @user = User.find(params[:participant_id]) + @user = T.let(User.find(params[:participant_id]), T.nilable(User)) end + sig { void } def set_email + user = T.must(@user) user_params = params[:user] - return if !@user.new_record? && user_params[:email_repeat].blank? + return if !user.new_record? && user_params[:email_repeat].blank? if user_params[:email].blank? - @user.errors.add(:email, 'needed') + user.errors.add(:email, 'needed') elsif user_params[:email] != user_params[:email_repeat] - @user.errors.add(:email_repeat, 'did not match') + user.errors.add(:email_repeat, 'did not match') else - @user.email = user_params[:email].strip + user.email = user_params[:email].strip end end + sig { params(user: User, user_params: T.untyped).returns(T::Boolean) } def maybe_update_password(user, user_params) if user_params[:old_password].present? || user_params[:password].present? if user.managed_externally? @@ -116,11 +130,12 @@ def maybe_update_password(user, user_params) end end + sig { returns(T.nilable(T::Hash[T.untyped, T.untyped])) } def set_user_fields return if params[:user_field].nil? changes = {} UserField.all.select { |f| f.visible_to?(current_user) }.each do |field| - value_record = @user.field_value_record(field) + value_record = T.must(@user).field_value_record(field) old_value = value_record.ruby_value value_record.set_from_form(params[:user_field][field.name]) new_value = value_record.ruby_value diff --git a/app/controllers/setup/course_assistants_controller.rb b/app/controllers/setup/course_assistants_controller.rb index e2f4a94df..61b64cef6 100644 --- a/app/controllers/setup/course_assistants_controller.rb +++ b/app/controllers/setup/course_assistants_controller.rb @@ -1,57 +1,64 @@ +# typed: strict # frozen_string_literal: true class Setup::CourseAssistantsController < Setup::SetupController before_action :set_course + sig { void } def index authorize! :modify_assistants, @course - @setup_in_progress = setup_in_progress? + course = T.must(@course) + @setup_in_progress = T.let(setup_in_progress?, T.nilable(T::Boolean)) - @assistants = @course.assistants - @assistantship = Assistantship.new + @assistants = T.let(course.assistants, T.nilable(ActiveRecord::Relation)) + @assistantship = T.let(Assistantship.new, T.nilable(Assistantship)) if setup_in_progress? print_setup_phases(3) else - add_breadcrumb("Assistants for course #{@course.title}") + add_breadcrumb("Assistants for course #{course.title}") end end + sig { void } def create authorize! :modify_assistants, @course + course = T.must(@course) if params[:commit] == 'Add new assistant' new_assistant = User.find_by('lower(email) = ?', assistant_params[:email].downcase) - @assistantship = Assistantship.new(user: new_assistant, course: @course) + @assistantship = Assistantship.new(user: new_assistant, course: course) if @assistantship.save - redirect_to setup_organization_course_course_assistants_path, notice: "Assistant #{new_assistant.email} added" + redirect_to setup_organization_course_course_assistants_path, notice: "Assistant #{T.must(new_assistant).email} added" else @setup_in_progress = setup_in_progress? - @assistants = @course.assistants + @assistants = course.assistants if setup_in_progress? print_setup_phases(3) else - add_breadcrumb("Assistants for course #{@course.title}") + add_breadcrumb("Assistants for course #{course.title}") end render :index end elsif params[:commit] == 'Continue' redirect_to setup_organization_course_course_finisher_index_path else - redirect_to organization_course_path(@organization, @course) + redirect_to organization_course_path(@organization, course) end end + sig { void } def destroy authorize! :modify_assistants, @course @assistantship = Assistantship.find(params[:id]) - destroyed_email = @assistantship.user.email + destroyed_email = T.must(@assistantship.user).email @assistantship.destroy! redirect_to setup_organization_course_course_assistants_path, notice: "Assistant #{destroyed_email} removed from course." end private + sig { returns(ActionController::Parameters) } def assistant_params params.permit(:email) end diff --git a/app/controllers/setup/course_chooser_controller.rb b/app/controllers/setup/course_chooser_controller.rb index 1e65d0800..a67a37c88 100644 --- a/app/controllers/setup/course_chooser_controller.rb +++ b/app/controllers/setup/course_chooser_controller.rb @@ -1,17 +1,21 @@ +# typed: strict # frozen_string_literal: true class Setup::CourseChooserController < Setup::SetupController + extend T::Sig before_action :set_course_from_session + sig { void } def index authorize! :teach, @organization save_wizard_to_session(1) print_setup_phases(1) - @course_templates = CourseTemplate.available.order(Arel.sql('LOWER(title)')) - @setup_in_progress = setup_in_progress? + @course_templates = T.let(CourseTemplate.available.order(Arel.sql('LOWER(title)')), T.nilable(ActiveRecord::Relation)) + @setup_in_progress = T.let(setup_in_progress?, T.nilable(T::Boolean)) end private + sig { void } def set_course_from_session if setup_in_progress? && !session[:ongoing_course_setup][:course_id].nil? @course = Course.find(session[:ongoing_course_setup][:course_id]) diff --git a/app/controllers/setup/course_details_controller.rb b/app/controllers/setup/course_details_controller.rb index f710770d4..af7001c51 100644 --- a/app/controllers/setup/course_details_controller.rb +++ b/app/controllers/setup/course_details_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require 'course_refresh_database_updater' @@ -5,50 +6,68 @@ class Setup::CourseDetailsController < Setup::SetupController before_action :set_course, except: %i[new create] + sig { void } def new authorize! :teach, @organization - @course_template = CourseTemplate.find(params[:template_id]) - authorize! :clone, @course_template + course_template = CourseTemplate.find(params[:template_id]) + @course_template = T.let(course_template, T.nilable(CourseTemplate)) + authorize! :clone, course_template print_setup_phases(2) - @course = Course.new_from_template(@course_template) + @course = Course.new_from_template(course_template) @course.organization = @organization end + sig { void } def custom authorize! :create_custom_course, @organization print_setup_phases(2) @course = Course.new @course.organization = @organization - @custom = true + @custom = T.let(true, T.nilable(T::Boolean)) render 'new' end + sig { void } + def edit + authorize! :teach, @organization + @setup_in_progress = T.let(setup_in_progress?, T.nilable(T::Boolean)) + if setup_in_progress? + print_setup_phases(2) + else + add_course_breadcrumb + add_breadcrumb('Edit details') + end + end + + sig { void } def create authorize! :teach, @organization custom = params[:course][:course_template_id].blank? authorize! :create_custom_course, @organization if custom + organization = T.must(@organization) + create_params = custom ? course_params_for_create_custom : course_params_for_create_from_template input_name = create_params[:name] - create_params[:name] = @organization.slug + '-' + input_name + create_params[:name] = T.must(organization.slug) + '-' + input_name @course = Course.new(create_params) @course.organization = @organization if @course.save # Do refresh if custom course and course_template first course - if custom || !@course.course_template.cache_exists? + if custom || !T.must(@course.course_template).cache_exists? refresh_course(@course) else - template_refresh = @course.course_template.course_template_refreshes.last + template_refresh = T.must(T.must(@course.course_template).course_template_refreshes.last) CourseRefreshDatabaseUpdater.new.refresh_course(@course, template_refresh[:langs_refresh_output]) end update_setup_course(@course.id) - redirect_to setup_organization_course_course_assistants_path(@organization.slug, @course.id) + redirect_to setup_organization_course_course_assistants_path(organization.slug, @course.id) else @course_template = @course.course_template unless custom @course.name = input_name @@ -58,25 +77,17 @@ def create end end - def edit - authorize! :teach, @organization - @setup_in_progress = setup_in_progress? - if setup_in_progress? - print_setup_phases(2) - else - add_course_breadcrumb - add_breadcrumb('Edit details') - end - end - + sig { void } def update authorize! :teach, @organization - if @course.update(course_params) + organization = T.must(@organization) + course = T.must(@course) + if course.update(course_params) if setup_in_progress? - redirect_to setup_organization_course_course_assistants_path(@organization.slug, @course.id), + redirect_to setup_organization_course_course_assistants_path(organization.slug, course.id), notice: 'Course details updated.' else - redirect_to organization_course_path(@organization, @course), notice: 'Course details updated.' + redirect_to organization_course_path(organization, course), notice: 'Course details updated.' end else render :edit @@ -84,20 +95,24 @@ def update end private + sig { params(course: Course).returns(CourseTemplateRefresh) } def refresh_course(course) course.refresh(current_user.id) end + sig { returns(ActionController::Parameters) } def course_params_for_create_from_template params.require(:course).permit(:name, :title, :description, :material_url, :course_template_id, :moocfi_id) end + sig { returns(ActionController::Parameters) } def course_params_for_create_custom params.require(:course).permit(:name, :title, :description, :material_url, :source_url, :git_branch, :source_backend, :moocfi_id) end + sig { returns(ActionController::Parameters) } def course_params - if @course.custom? + if T.must(@course).custom? params.require(:course).permit(:title, :description, :material_url, :source_url, :git_branch, :external_scoreboard_url, :moocfi_id) else params.require(:course).permit(:title, :description, :material_url, :external_scoreboard_url, :moocfi_id) diff --git a/app/controllers/setup/course_finisher_controller.rb b/app/controllers/setup/course_finisher_controller.rb index 519c788db..a6a2ef19e 100644 --- a/app/controllers/setup/course_finisher_controller.rb +++ b/app/controllers/setup/course_finisher_controller.rb @@ -1,8 +1,10 @@ +# typed: strict # frozen_string_literal: true class Setup::CourseFinisherController < Setup::SetupController before_action :set_course + sig { void } def index authorize! :teach, @organization unless setup_in_progress? @@ -11,18 +13,20 @@ def index print_setup_phases(4) end + sig { void } def create authorize! :teach, @organization + course = T.must(@course) if params[:commit] == 'Publish now' - @course.enabled! - @course.save! + course.enabled! + course.save! elsif params[:commit] == 'Finish and publish later' - @course.disabled! - @course.save! + course.disabled! + course.save! end reset_setup_session - redirect_to organization_course_path(@organization, @course) + redirect_to organization_course_path(@organization, course) end end diff --git a/app/controllers/setup/course_timings_controller.rb b/app/controllers/setup/course_timings_controller.rb index bd965f849..8e2910d83 100644 --- a/app/controllers/setup/course_timings_controller.rb +++ b/app/controllers/setup/course_timings_controller.rb @@ -1,13 +1,15 @@ +# typed: strict # frozen_string_literal: true # Deprecated class Setup::CourseTimingsController < Setup::SetupController before_action :set_course + sig { void } def show authorize! :teach, @organization - @setup_in_progress = setup_in_progress? + @setup_in_progress = T.let(setup_in_progress?, T.nilable(T::Boolean)) if setup_in_progress? print_setup_phases(3) else @@ -15,9 +17,10 @@ def show add_breadcrumb('Timing') end - @group_array = groups_as_array + @group_array = T.let(groups_as_array, T.nilable(T::Array[String])) end + sig { void } def update authorize! :teach, @organization @@ -69,83 +72,95 @@ def update else raise 'Wrong button' end - rescue UnlockSpec::InvalidSyntaxError => e - redirect_to setup_organization_course_course_timing_path, alert: e.to_s - rescue DeadlineSpec::InvalidSyntaxError => e + rescue UnlockSpec::InvalidSyntaxError, DeadlineSpec::InvalidSyntaxError => e redirect_to setup_organization_course_course_timing_path, alert: e.to_s end private + sig { void } def save_unlocks authorize! :manage_unlocks, @course + course = T.must(@course) groups = group_params groups.each do |name, unlock_data| + group = course.exercise_group_by_name(name) + next if group.nil? if unlock_data[:_unlock_option] == 'no_unlock' unlock_condition = '' elsif unlock_data[:_unlock_option] == 'percentage_from' unlock_condition = "#{unlock_data[:_percentage_required]}% from #{unlock_data[:_unlock_groupname]}" end - @course.exercise_group_by_name(name).group_unlock_conditions = Array(unlock_condition).to_json - UncomputedUnlock.create_all_for_course(@course) + group.group_unlock_conditions = Array(unlock_condition).to_json + UncomputedUnlock.create_all_for_course(course) end end + sig { void } def clear_all_unlocks authorize! :manage_unlocks, @course - @course.exercise_groups.each do |eg| + T.must(@course).exercise_groups.each do |eg| eg.group_unlock_conditions = [''].to_json end end + sig { params(percentage: T.any(String, Integer)).void } def unlocks_previous_set_completed(percentage = 80) authorize! :manage_unlocks, @course - prevname = nil - @course.exercise_groups.each do |eg| + prevname = T.let(nil, T.nilable(String)) + T.must(@course).exercise_groups.each do |eg| eg.group_unlock_conditions = Array("#{percentage}% from #{prevname}").to_json unless prevname.nil? prevname = eg.name end end + sig { void } def save_deadlines authorize! :manage_deadlines, @course + course = T.must(@course) groups = group_params groups.each do |name, deadlines| - hard_deadlines = [deadlines[:hard][:static], ''].to_json - @course.exercise_group_by_name(name).hard_group_deadline = hard_deadlines + group = course.exercise_group_by_name(name) + next if group.nil? + group.hard_group_deadline = [deadlines[:hard][:static], ''].to_json end end + sig { void } def clear_all_deadlines authorize! :manage_deadlines, @course - @course.exercise_groups.each do |eg| + T.must(@course).exercise_groups.each do |eg| eg.hard_group_deadline = ['', ''].to_json end end + # first_date: the first_set_date param, a one-element array of a date string. + sig { params(first_date: T.untyped, days: Integer).void } def fill_deadlines_with_interval(first_date, days = 7) authorize! :manage_deadlines, @course date = first_date - @course.exercise_groups.each do |eg| + T.must(@course).exercise_groups.each do |eg| eg.hard_group_deadline = date.to_json date = Array((DateTime.parse(date[0]) + days.days).strftime('%Y-%m-%d')) end end + sig { params(date: T.untyped).void } def fill_all_deadlines_with(date) authorize! :manage_deadlines, @course - @course.exercise_groups.each do |eg| + T.must(@course).exercise_groups.each do |eg| eg.hard_group_deadline = date.to_json end end - private + sig { returns(T::Array[String]) } def groups_as_array - @course.exercise_groups.each_with_object(['']) do |group, array| + T.must(@course).exercise_groups.each_with_object(['']) do |group, array| array << group.name end end + sig { returns(T.any(ActionController::Parameters, T::Hash[T.untyped, T.untyped])) } def group_params sliced = params.slice(:group, :empty_group) groups = sliced[:group] || {} diff --git a/app/controllers/setup/organizations_controller.rb b/app/controllers/setup/organizations_controller.rb index c979c907d..c1bbdaf8c 100644 --- a/app/controllers/setup/organizations_controller.rb +++ b/app/controllers/setup/organizations_controller.rb @@ -1,18 +1,32 @@ +# typed: strict # frozen_string_literal: true class Setup::OrganizationsController < Setup::SetupController + extend T::Sig skip_authorization_check only: %i[index new] + sig { void } def index redirect_to setup_start_index_path end + sig { void } def new return respond_unauthorized('Please log in first to create new organization') unless can? :request, :organization add_breadcrumb 'Create new organization' @organization = Organization.new end + sig { void } + def edit + authorize! :edit, @organization + + add_organization_breadcrumb + add_breadcrumb 'Edit details' + @cant_edit_slug = T.let(!current_user.administrator?, T.nilable(T::Boolean)) + end + + sig { void } def create authorize! :request, :organization @organization = Organization.init(organization_params, current_user) @@ -26,19 +40,12 @@ def create end end - def edit - authorize! :edit, @organization - - add_organization_breadcrumb - add_breadcrumb 'Edit details' - @cant_edit_slug = !current_user.administrator? - end - + sig { void } def update authorize! :edit, @organization authorize! :edit_slug, @organization unless organization_params[:slug].nil? - if @organization.update(organization_params) + if T.must(@organization).update(organization_params) redirect_to organization_path(@organization), notice: 'Organization was successfully updated.' else add_organization_breadcrumb @@ -49,10 +56,12 @@ def update end private + sig { void } def set_organization - @organization = Organization.find_by(slug: params[:id]) unless params[:id].nil? + @organization = Organization.find_by!(slug: params[:id]) unless params[:id].nil? end + sig { returns(ActionController::Parameters) } def organization_params params.require(:organization).permit(:name, :information, :website, :logo, :slug, :contact_information, :phone, :email, :disabled_reason) end diff --git a/app/controllers/setup/setup_controller.rb b/app/controllers/setup/setup_controller.rb index 5e5c78669..726bd11e8 100644 --- a/app/controllers/setup/setup_controller.rb +++ b/app/controllers/setup/setup_controller.rb @@ -1,9 +1,11 @@ +# typed: strict # frozen_string_literal: true class Setup::SetupController < ApplicationController + extend T::Sig before_action :set_organization, :add_setup_breadcrumb - STEPS = + STEPS = T.let( [ { # step_number: 1, @@ -31,8 +33,11 @@ class Setup::SetupController < ApplicationController title: 'Finish', path: :setup_organization_course_course_finisher_index_path } - ].freeze + ].freeze, + T::Array[{ title: String, path: Symbol }] + ) + sig { params(title: String).returns(T.nilable(Integer)) } def step_number(title) found = 0 STEPS.each do |st| @@ -42,15 +47,18 @@ def step_number(title) nil end + sig { params(step_number: Integer).returns(Symbol) } def link_to_next_step(step_number) - STEPS[step_number][:path] + STEPS.fetch(step_number)[:path] end + sig { void } def add_setup_breadcrumb add_breadcrumb 'Setup', setup_start_index_path add_breadcrumb 'Create new course' if controller_name.starts_with?('course_') && setup_in_progress? end + sig { params(phase: Integer).void } def print_setup_phases(phase = 0) update_setup_phase(phase) unless phase == 2 maxphase = setup_in_progress? ? session[:ongoing_course_setup][:phase] : phase @@ -66,20 +74,27 @@ def print_setup_phases(phase = 0) end options = {} options = { pass_parameters: true } if setup_in_progress? && course_choosed? - add_phase (i + 1).to_s + '. ' + STEPS[i][:title], type, path, options + add_phase (i + 1).to_s + '. ' + st[:title], type, path, options end end - def add_phase(name, type, url = '', options = {}) - @course_setup_phases ||= [] - if options[:pass_parameters] - url = url.to_s + "('#{@organization.slug}', #{@course.id})" - end - url = eval(url.to_s) if url =~ /_path|_url|@/ - @course_setup_phases << { name: name, url: url, type: type } + # route_helper: called with the current organization and course when options[:pass_parameters] is set. + sig { params(name: String, type: String, route_helper: T.nilable(Symbol), options: T::Hash[Symbol, T.untyped]).void } + def add_phase(name, type, route_helper = nil, options = {}) + phases = @course_setup_phases ||= T.let([], T.nilable(T::Array[T::Hash[Symbol, T.untyped]])) + url = + if route_helper.nil? + nil + elsif options[:pass_parameters] + send(route_helper, T.must(@organization).slug, T.must(@course).id) + else + send(route_helper) + end + phases << { name: name, url: url, type: type } end private + sig { params(phase: Integer).void } def save_wizard_to_session(phase = 1) return if setup_in_progress? session[:ongoing_course_setup] = { @@ -89,6 +104,7 @@ def save_wizard_to_session(phase = 1) } end + sig { returns(T::Boolean) } def setup_in_progress? return false if session[:ongoing_course_setup].nil? if !session[:ongoing_course_setup][:course_id].nil? && !@course.nil? @@ -98,34 +114,40 @@ def setup_in_progress? end end + sig { returns(T::Boolean) } def course_choosed? return false if session[:ongoing_course_setup].nil? !session[:ongoing_course_setup][:course_id].nil? end + sig { void } def reset_setup_session session[:ongoing_course_setup] = nil end + sig { params(phase: Integer).void } def update_setup_phase(phase) if setup_in_progress? session[:ongoing_course_setup][:phase] = phase if session[:ongoing_course_setup][:phase] < phase end end + sig { params(id: T.nilable(Integer)).void } def update_setup_course(id = nil) return unless setup_in_progress? session[:ongoing_course_setup][:course_id] = if id.nil? - @course.id + T.must(@course).id else id end end + sig { void } def set_organization - @organization = Organization.find_by(slug: params[:organization_id]) + @organization = Organization.find_by!(slug: params[:organization_id]) unless params[:organization_id].nil? end + sig { void } def set_course @course = Course.find(params[:course_id]) unless params[:course_id].nil? end diff --git a/app/controllers/setup/start_controller.rb b/app/controllers/setup/start_controller.rb index 5cff9e290..75e9c07c8 100644 --- a/app/controllers/setup/start_controller.rb +++ b/app/controllers/setup/start_controller.rb @@ -1,10 +1,14 @@ +# typed: strict # frozen_string_literal: true class Setup::StartController < Setup::SetupController + extend T::Sig skip_authorization_check only: [:index] + sig { void } def index - @my_organizations = Organization.taught_organizations(current_user) - @organization = @my_organizations.first if @my_organizations.count == 1 + my_organizations = Organization.taught_organizations(current_user) + @my_organizations = T.let(my_organizations, T.nilable(ActiveRecord::Relation)) + @organization = my_organizations.first if my_organizations.count == 1 end end diff --git a/app/controllers/solutions_controller.rb b/app/controllers/solutions_controller.rb index b3cf3770b..9d2474ea0 100644 --- a/app/controllers/solutions_controller.rb +++ b/app/controllers/solutions_controller.rb @@ -1,17 +1,19 @@ +# typed: strict # frozen_string_literal: true # Returns the suggestion solution as a ZIP. class SolutionsController < ApplicationController + sig { void } def show @exercise = Exercise.find(params[:exercise_id]) - @course = @exercise.course + @course = T.must(@exercise.course) @organization = @course.organization add_course_breadcrumb add_exercise_breadcrumb add_breadcrumb 'Suggested solution' - @solution = @exercise.solution + @solution = T.let(@exercise.solution, T.nilable(Solution)) begin authorize! :read, @solution rescue CanCan::AccessDenied diff --git a/app/controllers/stats_controller.rb b/app/controllers/stats_controller.rb index cf6c64ed9..43aff9f52 100644 --- a/app/controllers/stats_controller.rb +++ b/app/controllers/stats_controller.rb @@ -1,10 +1,13 @@ +# typed: strict # frozen_string_literal: true # Shows the various statistics under /stats. class StatsController < ApplicationController + extend T::Sig skip_authorization_check except: [:organization_stats_index] before_action :set_organization + sig { void } def index get_vars if @course @@ -21,6 +24,7 @@ def index end end + sig { void } def show get_vars page = params[:id] @@ -36,25 +40,29 @@ def show end private + sig { void } def get_vars @course = Course.find(params[:course_id]) if params[:course_id] end + sig { void } def course_stats_index respond_to do |format| format.html { render template: 'courses/stats/index' } end end + sig { void } def organization_stats_index authorize! :view_statistics, @organization - @stats = Stats.courses(@organization) + @stats = T.let(Stats.courses(@organization), T.nilable(T::Hash[Symbol, T.untyped])) respond_to do |format| format.html { render } format.json { render json: @stats, callback: params[:jsonp] } end end + sig { void } def general_stats_index @stats = Stats.courses respond_to do |format| @@ -63,6 +71,7 @@ def general_stats_index end end + sig { params(page: String).void } def course_stats_show(page) case page when 'submissions' @@ -74,46 +83,54 @@ def course_stats_show(page) end end + sig { void } def course_stats_show_submissions - return respond_not_found('No submissions yet') if @course.submissions.empty? + course = T.must(@course) + return respond_not_found('No submissions yet') if course.submissions.empty? - @start_time = + start_time = if params[:start_time] - then Time.zone.parse(params[:start_time]) - else @course.time_of_first_submission.to_date.in_time_zone + time_param(:start_time) + else + T.must(course.time_of_first_submission).to_date.in_time_zone end - @end_time = + return respond_with_error('Invalid start_time', 400) if start_time.nil? + end_time = if params[:end_time] - then Time.zone.parse(params[:end_time]) - else @course.time_of_last_submission.to_date.in_time_zone + time_param(:end_time) + else + T.must(course.time_of_last_submission).to_date.in_time_zone end - @time_unit = param_as_one_of(:time_unit, [nil, 'minute', 'hour', 'day']) - @time_unit = 'day' if @time_unit.nil? + return respond_with_error('Invalid end_time', 400) if end_time.nil? + time_unit = param_as_one_of(:time_unit, [nil, 'minute', 'hour', 'day']) || 'day' + @start_time = T.let(start_time, T.nilable(ActiveSupport::TimeWithZone)) + @end_time = T.let(end_time, T.nilable(ActiveSupport::TimeWithZone)) + @time_unit = T.let(time_unit, T.nilable(String)) respond_to do |format| format.html { render template: 'courses/stats/submissions', layout: 'bare' } format.json do - records = @course.submissions - .select(['COUNT(*) c', "date_trunc('#{@time_unit}', #{expr_for_time_in_time_zone('created_at')}) t"]) - .group('t') - .where('created_at >= ?', @start_time) - .where(user: User.legitimate_students) - .where('created_at < ?', @end_time) + records = course.submissions + .select(['COUNT(*) c', "date_trunc('#{time_unit}', #{expr_for_time_in_time_zone('created_at')}) t"]) + .group('t') + .where(created_at: start_time..) + .where(user: User.legitimate_students) + .where(created_at: ...end_time) date_format = '%Y-%m-%d %H:%M:%S' # query returns in this format, without timezone # date_format = '%FT%T.%LZ' # query returns in this format, without timezone lookup = {} for r in records - lookup[r.t.strftime(date_format)] = r.c.to_i + lookup[r['t'].strftime(date_format)] = r['c'].to_i end lookup.default = 0 result = [] - time = @start_time - while time < @end_time + time = start_time + while time < end_time result << lookup[time.strftime(date_format)] - time += 1.send(@time_unit) + time += 1.send(time_unit) end render json: result @@ -121,20 +138,22 @@ def course_stats_show_submissions end end + sig { void } def course_stats_show_submission_times - return respond_not_found('No submissions yet') if @course.submissions.empty? + course = T.must(@course) + return respond_not_found('No submissions yet') if course.submissions.empty? respond_to do |format| format.html { render template: 'courses/stats/submission_times', layout: 'bare' } format.json do - records = @course.submissions.where(user: User.legitimate_students).select([ + records = course.submissions.where(user: User.legitimate_students).select([ 'COUNT(*) c', "EXTRACT(HOUR FROM #{expr_for_time_in_time_zone('created_at')}) h" - ]).group('h').order('h ASC') + ]).group('h').order(:h) lookup = {} for r in records - lookup[r.h.to_i] = r.c.to_i + lookup[r['h'].to_i] = r['c'].to_i end lookup.default = 0 @@ -148,15 +167,25 @@ def course_stats_show_submission_times end end + sig { params(name: Symbol).returns(T.nilable(ActiveSupport::TimeWithZone)) } + def time_param(name) + Time.zone.parse(params[name].to_s) + rescue ArgumentError + nil + end + + sig { params(field: String).returns(String) } def expr_for_time_in_time_zone(field) connection = ActiveRecord::Base.connection "((#{field} AT TIME ZONE 'UTC') AT TIME ZONE #{connection.quote(Time.zone.name)})" end + sig { params(_page: String).void } def general_stats_show(_page) respond_not_found('No such stats page') end + sig { params(name: Symbol, valid_values: T::Array[T.nilable(String)]).returns(T.nilable(String)) } def param_as_one_of(name, valid_values) if valid_values.include?(params[name]) params[name] @@ -165,8 +194,8 @@ def param_as_one_of(name, valid_values) end end - private + sig { void } def set_organization - @organization = Organization.find_by(slug: params[:organization_id]) + @organization = Organization.find_by!(slug: params[:organization_id]) unless params[:organization_id].nil? end end diff --git a/app/controllers/status_controller.rb b/app/controllers/status_controller.rb index 983689fce..1a900a95b 100644 --- a/app/controllers/status_controller.rb +++ b/app/controllers/status_controller.rb @@ -1,12 +1,14 @@ +# typed: strict # frozen_string_literal: true # Shows the various statistics under /stats. class StatusController < ApplicationController + sig { void } def index authorize! :read_instance_state, nil - @stats = Rails.cache.fetch('submission-stats-cache') - @stats = JSON.parse(@stats) if @stats - @sandboxes = Rails.cache.fetch('sandbox-status-cache') - @sandboxes = JSON.parse(@sandboxes) if @sandboxes + stats_json = Rails.cache.fetch('submission-stats-cache') + @stats = T.let(stats_json && JSON.parse(stats_json), T.nilable(T::Hash[String, T.untyped])) + sandboxes_json = Rails.cache.fetch('sandbox-status-cache') + @sandboxes = T.let(sandboxes_json && JSON.parse(sandboxes_json), T.nilable(T::Array[T::Hash[String, T.untyped]])) end end diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb index af8b7e550..4f1db0d8e 100644 --- a/app/controllers/submissions_controller.rb +++ b/app/controllers/submissions_controller.rb @@ -1,3 +1,4 @@ +# typed: strict # frozen_string_literal: true require 'submission_processor' @@ -5,13 +6,16 @@ # Receives submissions and presents the full submission list and submission view. # Also handles rerun requests. class SubmissionsController < ApplicationController + extend T::Sig around_action :course_transaction before_action :get_course_and_exercise # Manually checked for #show and index skip_authorization_check only: %i[show index difference_with_solution] + sig { void } def index + course = T.must(@course) respond_to do |format| format.json do if params[:row_format] == 'datatables' @@ -21,20 +25,24 @@ def index end end format.html do # uses AJAX - respond_forbidden if !current_user.administrator? && @course.hide_submissions? - @organization = @course.organization + respond_forbidden if !current_user.administrator? && course.hide_submissions? + @organization = course.organization add_course_breadcrumb add_breadcrumb 'All submissions' end end end + sig { void } def show - @course ||= @submission.course - @exercise ||= @submission.exercise - @organization = @course.organization + submission = T.must(@submission) + @course ||= submission.course + @exercise ||= submission.exercise + course = T.must(@course) + exercise = @exercise + @organization = course.organization - @model_solution_token_used = ModelSolutionTokenUsed.where(course: @course, exercise_name: @exercise.name, user: @submission.user) + @model_solution_token_used = T.let(ModelSolutionTokenUsed.where(course: course, exercise_name: submission.exercise_name, user: submission.user), T.nilable(ActiveRecord::Relation)) add_course_breadcrumb add_exercise_breadcrumb @@ -42,48 +50,48 @@ def show respond_to do |format| format.html do - respond_forbidden if !current_user.administrator? && @course.hide_submissions? - @files = SourceFileList.for_submission(@submission) + respond_forbidden if !current_user.administrator? && course.hide_submissions? + @files = T.let(SourceFileList.for_submission(submission), T.nilable(T.any(SourceFileList, T::Array[T::Hash[Symbol, String]]))) end format.zip do - respond_forbidden if !current_user.administrator? && @course.hide_submissions? - send_data(@submission.return_file, filename: "#{@submission.user.login}-#{@exercise.name}-#{@submission.id}.zip") + respond_forbidden if !current_user.administrator? && course.hide_submissions? + send_data(submission.return_file, filename: "#{T.must(submission.user).login}-#{submission.exercise_name}-#{submission.id}.zip") end format.json do output = { api_version: ApiVersion::API_VERSION, - all_tests_passed: @submission.all_tests_passed?, - user_id: @submission.user_id, - login: @submission.user.login, - course: @course.name, - exercise_name: @submission.exercise.name, - status: @submission.status(current_user), - points: @submission.points_list, - validations: @submission.validations, - valgrind: @submission.valgrind, - solution_url: @exercise.solution.visible_to?(current_user) ? view_context.exercise_solution_url(@exercise) : nil, - submitted_at: @submission.created_at, - processing_time: @submission.processing_time, - reviewed: @submission.reviewed?, - requests_review: @submission.requests_review?, - paste_url: @submission.paste_available ? paste_url(@submission.paste_key) : nil, - message_for_paste: @submission.paste_available ? @submission.message_for_paste : nil, - missing_review_points: @exercise.missing_review_points_for(@submission.user) + all_tests_passed: submission.all_tests_passed?, + user_id: submission.user_id, + login: T.must(submission.user).login, + course: course.name, + exercise_name: submission.exercise_name, + status: submission.status(current_user), + points: submission.points_list, + validations: submission.validations, + valgrind: submission.valgrind, + solution_url: exercise && exercise.solution.visible_to?(current_user) ? view_context.exercise_solution_url(exercise) : nil, + submitted_at: submission.created_at, + processing_time: submission.processing_time, + reviewed: submission.reviewed?, + requests_review: submission.requests_review?, + paste_url: submission.paste_available ? paste_url(submission.paste_key) : nil, + message_for_paste: submission.paste_available ? submission.message_for_paste : nil, + missing_review_points: exercise ? exercise.missing_review_points_for(T.must(submission.user)) : [] } - output = output.merge( - case @submission.status(current_user) + status_fields = + case submission.status(current_user) when :processing then { - submissions_before_this: @submission.unprocessed_submissions_before_this, + submissions_before_this: submission.unprocessed_submissions_before_this, total_unprocessed: Submission.unprocessed_count } when :ok then { - test_cases: @submission.test_case_records, - feedback_questions: @course.feedback_questions.order(:position).map(&:record_for_api), - feedback_answer_url: submission_feedback_answers_url(@submission, format: :json) + test_cases: submission.test_case_records, + feedback_questions: course.feedback_questions.order(:position).map(&:record_for_api), + feedback_answer_url: submission_feedback_answers_url(submission, format: :json) } when :fail then { - test_cases: @submission.test_case_records + test_cases: submission.test_case_records } when :hidden then { all_tests_passed: nil, @@ -93,13 +101,13 @@ def show valgrind: nil } when :error then { - error: @submission.pretest_error + error: submission.pretest_error } end - ) + output = output.merge(T.must(status_fields)) output[:status] = :ok if output[:status] == :hidden if !!params[:include_files] - output[:files] = SourceFileList.for_submission(@submission).map { |f| { path: f.path, contents: f.contents } } + output[:files] = SourceFileList.for_submission(submission).map { |f| { path: f.path, contents: f.contents } } end render json: output @@ -107,12 +115,14 @@ def show end end + sig { void } def create if !params[:submission] || !params[:submission][:file] return respond_not_found('No ZIP file selected or failed to receive it') end - unless @exercise.submittable_by?(current_user) + exercise = T.must(@exercise) + unless exercise.submittable_by?(current_user) return respond_forbidden('Submissions for this exercise are no longer accepted.') end @@ -134,7 +144,7 @@ def create @submission = Submission.new( user: current_user, course: @course, - exercise: @exercise, + exercise: exercise, return_file: file_contents, params_json: submission_params.to_json, requests_review: !!params[:request_review], @@ -162,7 +172,7 @@ def create redirect_to(submission_path(@submission), notice: 'Submission received.') else - redirect_to(exercise_path(@exercise), + redirect_to(exercise_path(exercise), alert: errormsg) end end @@ -177,8 +187,9 @@ def create end end + sig { void } def update - submission = Submission.find(params[:id]) || respond_not_found + submission = Submission.find(params[:id]) authorize! :update, submission if params[:rerun] authorize! :rerun, submission @@ -193,32 +204,40 @@ def update end end + sig { void } def update_by_exercise - for submission in @exercise.submissions + exercise = T.must(@exercise) + for submission in exercise.submissions schedule_for_rerun(submission, -2) end - redirect_to exercise_path(@exercise), notice: 'Reruns scheduled' + redirect_to exercise_path(exercise), notice: 'Reruns scheduled' end + sig { void } def difference_with_solution - @course ||= @submission.course + submission = T.must(@submission) + @course ||= submission.course authorize! :teach, @course - @exercise ||= @submission.exercise - @organization = @course.organization + @exercise ||= submission.exercise + exercise = @exercise + return respond_not_found("This submission's exercise has been removed") if exercise.nil? + + @organization = T.must(@course).organization add_course_breadcrumb add_exercise_breadcrumb add_submission_breadcrumb add_breadcrumb 'Difference with model solution' - submission_files = SourceFileList.for_submission(@submission) - solution_files = SourceFileList.for_solution(@exercise.solution) + submission_files = SourceFileList.for_submission(submission) + solution_files = SourceFileList.for_solution(exercise.solution) files_in_list = Set.new - @files = [] + files = T.let([], T::Array[T::Hash[Symbol, String]]) + @files = files submission_files.each do |file| # TODO: In some exercises files may be named differently. Some kind of # similarity metric would be nice here model = solution_files.find { |solution_file| file.path == solution_file.path } - @files << { + files << { path: file.path, submission_contents: file.contents, model_contents: (model.nil? ? '' : model.contents) @@ -227,7 +246,7 @@ def difference_with_solution end solution_files.each do |file| next if files_in_list.include?(file.path) - @files << { + files << { path: file.path, submission_contents: '', model_contents: file.contents @@ -236,11 +255,13 @@ def difference_with_solution end private + sig { params(block: T.proc.void).void } def course_transaction(&block) Course.transaction(requires_new: true, &block) end # Ugly manual access control :/ + sig { void } def get_course_and_exercise submission_id = params[:id] || params[:submission_id] if submission_id @@ -250,14 +271,14 @@ def get_course_and_exercise @exercise = @submission.exercise elsif params[:exercise_id] @exercise = Exercise.find(params[:exercise_id]) - @course = Course.lock('FOR SHARE').find(@exercise.course_id) + @course = Course.lock('FOR SHARE').find(T.must(@exercise.course_id)) authorize! :read, @course authorize! :read, @exercise elsif params[:paste_key] @submission = Submission.find_by!(paste_key: params[:paste_key]) @exercise = @submission.exercise - @course = @exercise.course - @is_paste = true + @course = @submission.course + @is_paste = T.let(true, T.nilable(T::Boolean)) check_access! elsif params[:course_id] @course = Course.lock('FOR SHARE').find(params[:course_id]) @@ -268,14 +289,16 @@ def get_course_and_exercise end end + sig { params(submission: Submission, priority: Integer).void } def schedule_for_rerun(submission, priority) submission.set_to_be_reprocessed!(priority) end + sig { void } def index_json return respond_forbidden unless current_user.administrator? - submissions = @course.submissions + submissions = T.must(@course).submissions if params[:user_id] submissions = submissions.where(user_id: params[:user_id]) end @@ -288,17 +311,18 @@ def index_json } end + sig { void } def index_json_datatables - submissions = @course.submissions + submissions = T.must(@course).submissions unless current_user.administrator? || can?(:teach, @course) submissions = submissions.where(user_id: current_user.id) end if params[:max_id] - submissions = submissions.where('id <= ?', params[:max_id]) + submissions = submissions.where(id: ..params[:max_id]) end - submissions = submissions.includes(:user).order('id DESC') + submissions = submissions.includes(:user).order(id: :desc) remaining = submissions.count submissions_limited = submissions.limit(1000) Submission.eager_load_exercises(submissions_limited) @@ -306,48 +330,49 @@ def index_json_datatables render json: { remaining: remaining, max_id: params[:max_id].to_i, - last_id: submissions_limited.empty? ? nil : submissions_limited.last.id.to_i, + last_id: submissions_limited.empty? ? nil : T.must(submissions_limited.last).id.to_i, rows: view_context.submissions_for_datatables(submissions_limited) } end + sig { void } def check_access! if current_user.guest? raise CanCan::AccessDenied end - paste_visible = @submission.paste_visible_for?(current_user) + submission = T.must(@submission) + exercise = @exercise + course = T.must(@course) - paste_visibility = @exercise.paste_visibility - paste_visibility ||= @course.paste_visibility + paste_visible = submission.paste_visible_for?(current_user) + + paste_visibility = exercise&.paste_visibility + paste_visibility ||= course.paste_visibility paste_visibility ||= 'open' case paste_visibility when 'protected', 'secured' - respond_forbidden unless can?(:teach, @course) || @submission.user_id.to_s == current_user.id.to_s + respond_forbidden unless can?(:teach, course) || submission.user_id.to_s == current_user.id.to_s when 'no-tests-public' - respond_forbidden unless can?(:teach, @course) || @submission.created_at > 2.hours.ago || @submission.user_id.to_s == current_user.id.to_s + respond_forbidden unless can?(:teach, course) || T.must(submission.created_at) > 2.hours.ago || submission.user_id.to_s == current_user.id.to_s when 'completed-only' - respond_forbidden("You cannot see this paste because you haven't completed this exercise.") unless can?(:teach, @course) || @submission.user_id.to_s == current_user.id.to_s || @submission.exercise.completed_by?(current_user) || current_user.submissions.where(exercise_name: @exercise.name, pretest_error: nil, all_tests_passed: true).any? + respond_forbidden("You cannot see this paste because you haven't completed this exercise.") unless can?(:teach, course) || submission.user_id.to_s == current_user.id.to_s || exercise&.completed_by?(current_user) || current_user.submissions.where(exercise_name: submission.exercise_name, pretest_error: nil, all_tests_passed: true).any? when 'everyone' nil else - return if can?(:teach, @course) || @submission.user_id.to_s == current_user.id.to_s - if @submission.created_at > 2.hours.ago + return if can?(:teach, course) || submission.user_id.to_s == current_user.id.to_s + if T.must(submission.created_at) > 2.hours.ago respond_forbidden("You cannot see this paste because all tests passed and you haven't completed this exercise.") unless paste_visible - return else unless paste_visible - if @submission.exercise && !@submission.exercise.completed_by?(current_user) + submission_exercise = submission.exercise + if submission_exercise && !submission_exercise.completed_by?(current_user) respond_forbidden("You cannot see this paste because you haven't completed this exercise.") - return else respond_forbidden('You cannot see this paste because it was created over 2 hours ago.') end - return end end - - respond_forbidden('You cannot see this paste because all tests passed.') unless paste_visible end end end diff --git a/app/controllers/teachers_controller.rb b/app/controllers/teachers_controller.rb index 349fbb568..832445ed0 100644 --- a/app/controllers/teachers_controller.rb +++ b/app/controllers/teachers_controller.rb @@ -1,17 +1,21 @@ +# typed: strict # frozen_string_literal: true class TeachersController < ApplicationController + extend T::Sig before_action :set_organization + sig { void } def index authorize! :teach, @organization ordering = Arel.sql('LOWER(login)') - @teachers = @organization.teachers.order(ordering) - @teachership = Teachership.new + @teachers = T.let(T.must(@organization).teachers.order(ordering), T.nilable(ActiveRecord::Relation)) + @teachership = T.let(Teachership.new, T.nilable(Teachership)) add_organization_breadcrumb add_breadcrumb 'Manage teachers' end + sig { void } def create authorize! :teach, @organization @@ -19,26 +23,29 @@ def create @teachership = Teachership.new(user: user, organization: @organization) if @teachership.save - redirect_to organization_teachers_path, notice: "Teacher #{user.email} added to organization" + redirect_to organization_teachers_path, notice: "Teacher #{T.must(user).email} added to organization" else - @teachers = @organization.teachers + @teachers = T.must(@organization).teachers render :index end end + sig { void } def destroy authorize! :remove_teacher, @organization @teachership = Teachership.find(params[:id]) - destroyed_username = @teachership.user.login + destroyed_username = T.must(@teachership.user).login @teachership.destroy! redirect_to organization_teachers_path, notice: "Teacher #{destroyed_username} removed from organization" end private + sig { void } def set_organization - @organization = Organization.find_by(slug: params[:organization_id]) + @organization = Organization.find_by!(slug: params[:organization_id]) end + sig { returns(ActionController::Parameters) } def teacher_params params.permit(:email) end diff --git a/app/controllers/unlocks_controller.rb b/app/controllers/unlocks_controller.rb index 223a7bb82..e8450366d 100644 --- a/app/controllers/unlocks_controller.rb +++ b/app/controllers/unlocks_controller.rb @@ -1,12 +1,15 @@ +# typed: strict # frozen_string_literal: true # Receives explicit unlock requests and shows a web UI for making them. class UnlocksController < ApplicationController + extend T::Sig + sig { void } def show @course = Course.find(params[:course_id]) authorize! :read, @course - @organization = Organization.find_by(slug: params[:organization_id]) - @exercises = @course.unlockable_exercises_for(current_user) + @organization = Organization.find_by!(slug: params[:organization_id]) + @exercises = T.let(@course.unlockable_exercises_for(current_user), T.nilable(T::Array[Exercise])) respond_to do |format| format.html do @@ -16,10 +19,11 @@ def show end end + sig { void } def create @course = Course.find(params[:course_id]) authorize! :read, @course - @organization = Organization.find_by(slug: params[:organization_id]) + @organization = Organization.find_by!(slug: params[:organization_id]) @exercises = @course.unlockable_exercises_for(current_user) Unlock.unlock_exercises(@exercises, current_user) diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 518a28c73..08717d482 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -1,17 +1,32 @@ +# typed: strict # frozen_string_literal: true # Presents the "register user" and "edit profile" views. class UsersController < ApplicationController + extend T::Sig skip_authorization_check after_action :remove_x_frame_options_header_when_bare_layout, only: %i[new create show] + sig { void } def index raise CanCan::AccessDenied if current_user.guest? respond_forbidden unless current_user.administrator? - @users = User.search(params[:search]) + @users = T.let(User.search(params[:search]), T.nilable(ActiveRecord::Relation)) end + sig { void } + def show + add_breadcrumb 'My account', user_path + + if current_user.guest? + raise CanCan::AccessDenied + else + @user = current_user + end + end + + sig { void } def new authorize! :create, User alternative_location = SiteSetting.all_settings['signup_location'] @@ -22,10 +37,11 @@ def new # Logged in from this page. No need to be here, so redirect away redirect_to root_path else - @user = User.new + @user = T.let(User.new, T.nilable(User)) end end + sig { void } def create authorize! :create, User @@ -62,20 +78,12 @@ def create end end - def show - add_breadcrumb 'My account', user_path - - if current_user.guest? - raise CanCan::AccessDenied - else - @user = current_user - end - end - + sig { void } def update User.transaction do @user = current_user - @email_before = @user.email + email_before = @user.email + @email_before = T.let(email_before, T.nilable(String)) set_email password_changed = maybe_update_password(@user, params[:user]) @@ -84,7 +92,7 @@ def update if @user.errors.empty? && @user.save # Password changed locally: migrate the user to courses.mooc.fi @user.post_new_user_to_courses_mooc_fi(params[:user][:password]) if password_changed && !@user.managed_externally? - RecentlyChangedUserDetail.email_changed.create!(old_value: @email_before, new_value: @user.email, username: @user.login, user_id: @user.id) unless @email_before.casecmp(@user.email).zero? + RecentlyChangedUserDetail.email_changed.create!(old_value: email_before, new_value: @user.email, username: @user.login, user_id: @user.id) unless T.must(email_before.casecmp(@user.email)).zero? flash[:notice] = if password_changed 'Changes saved and password changed' else @@ -98,6 +106,7 @@ def update end end + sig { void } def confirm_email language = params[:language] VerificationToken.unscoped.email.find_by!(user_id: params[:user_id], token: params[:id]) @@ -109,6 +118,7 @@ def confirm_email redirect_to redirect_path, notice: 'Your email address has been verified!', allow_other_host: true end + sig { void } def send_verification_email user = User.find(params[:user_id]) raise 'Access denied' if user != current_user && !current_user.administrator? @@ -117,11 +127,13 @@ def send_verification_email redirect_to root_path, notice: "Verification email sent to #{user.email}." end + sig { void } def verify_destroying_user @user = authenticate_current_user_destroy VerificationToken.delete_user.find_by!(user: @user, token: params[:id]) end + sig { void } def destroy_user im_sure = params[:im_sure] if im_sure != '1' @@ -146,6 +158,7 @@ def destroy_user redirect_to root_url, notice: 'Your account has been permanently destroyed.' end + sig { void } def send_destroy_email user = authenticate_current_user_destroy UserMailer.destroy_confirmation(user).deliver_now @@ -153,12 +166,14 @@ def send_destroy_email end private + sig { returns(User) } def authenticate_current_user_destroy user = User.find(params[:user_id]) authorize! :destroy, user user end + sig { params(status: T.anything).returns(String) } def failed_authentication_alert(status) case status when :unavailable @@ -170,33 +185,38 @@ def failed_authentication_alert(status) end end + sig { void } def set_email + user = T.must(@user) user_params = params[:user] - return if !@user.new_record? && user_params[:email_repeat].blank? + return if !user.new_record? && user_params[:email_repeat].blank? if user_params[:email].blank? - @user.errors.add(:email, 'needed') + user.errors.add(:email, 'needed') elsif user_params[:email] != user_params[:email_repeat] - @user.errors.add(:email_repeat, 'did not match') + user.errors.add(:email_repeat, 'did not match') else - @user.email = user_params[:email].strip + user.email = user_params[:email].strip end end + sig { void } def set_password + user = T.must(@user) user_params = params[:user] if user_params[:password].blank? - @user.errors.add(:password, 'needed') + user.errors.add(:password, 'needed') elsif user_params[:password].length > 1000 - @user.errors.add(:password, 'cannot be over 1000 characters') + user.errors.add(:password, 'cannot be over 1000 characters') elsif user_params[:password] != user_params[:password_repeat] - @user.errors.add(:password_repeat, 'did not match') + user.errors.add(:password_repeat, 'did not match') else - @user.password = user_params[:password] + user.password = user_params[:password] end end + sig { params(user: User, user_params: T.untyped).returns(T::Boolean) } def maybe_update_password(user, user_params) if user_params[:old_password].present? || user_params[:password].present? if user.managed_externally? @@ -226,11 +246,12 @@ def maybe_update_password(user, user_params) end end + sig { returns(T.nilable(T::Hash[T.untyped, T.untyped])) } def set_user_fields return if params[:user_field].nil? changes = {} UserField.all.select { |f| f.visible_to?(current_user) }.each do |field| - value_record = @user.field_value_record(field) + value_record = T.must(@user).field_value_record(field) old_value = value_record.ruby_value value_record.set_from_form(params[:user_field][field.name]) new_value = value_record.ruby_value diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index acdc9ccef..b991b6102 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,12 +1,16 @@ +# typed: strict # frozen_string_literal: true require 'tailoring' module ApplicationHelper + extend T::Sig + sig { returns(Tailoring) } def tailoring Tailoring.get end + sig { params(name: String, path: String, options: T::Hash[Symbol, T.untyped]).returns(ActiveSupport::SafeBuffer) } def navigation_link(name, path, options = {}) class_name = options[:class] || '' class_addition = request.path.start_with?(path) ? 'nav-link active' : 'nav-link' @@ -14,6 +18,7 @@ def navigation_link(name, path, options = {}) link_to name, path, options end + sig { params(name: T.nilable(String), path: String, options: T::Hash[Symbol, T.untyped]).returns(ActiveSupport::SafeBuffer) } def active_on_current_path_link_to(name, path, options = {}) if request.path == path classes = options[:class] || '' @@ -23,6 +28,15 @@ def active_on_current_path_link_to(name, path, options = {}) link_to(name, path, options) end + # `tags` may be omitted and the options hash passed in its place. + sig do + params( + label: String, + tags: T.nilable(T.any(String, T::Hash[Symbol, T.untyped])), + options: T::Hash[Symbol, T.untyped], + block: T.nilable(T.proc.returns(T.untyped)) + ).returns(ActiveSupport::SafeBuffer) + end def labeled(label, tags = nil, options = {}, &block) if tags.is_a?(Hash) && options.empty? options = tags @@ -38,7 +52,7 @@ def labeled(label, tags = nil, options = {}, &block) tags = tags.html_safe if tags =~ /id\s*=\s*"([^"]+)"/ - target = ' for="' + Regexp.last_match(1) + '"' + target = " for=\"#{Regexp.last_match(1)}\"" else raise 'Cannot label a tag without an id' end @@ -62,6 +76,14 @@ def labeled(label, tags = nil, options = {}, &block) end end + sig do + params( + label: String, + tags: T.nilable(T.any(String, T::Hash[Symbol, T.untyped])), + options: T::Hash[Symbol, T.untyped], + block: T.nilable(T.proc.returns(T.untyped)) + ).returns(ActiveSupport::SafeBuffer) + end def labeled_field(label, tags = nil, options = {}, &block) cls = ['field'] cls << options[:super_class].split(' ') if options[:super_class] @@ -69,6 +91,7 @@ def labeled_field(label, tags = nil, options = {}, &block) raw("