diff --git a/.config/.cypress.js b/.config/.cypress.js index 5d8e10485..da8f635df 100644 --- a/.config/.cypress.js +++ b/.config/.cypress.js @@ -3,5 +3,35 @@ module.exports = { // Base URL is set via Docker environment variable viewportHeight: 1000, viewportWidth: 1400, + + // https://docs.cypress.io/api/plugins/browser-launch-api#Changing-browser-preferences + setupNodeEvents(on, _config) { + on("before:browser:launch", (browser, launchOptions) => { + if (browser.family === "chromium" && browser.name !== "electron") { + // auto open devtools + launchOptions.args.push("--auto-open-devtools-for-tabs"); + + // TODO (clipboard): We use the obsolete clipboard API from browsers, i.e. + // document.execCommand("copy"). There's a new Clipboard API that is supported + // by modern browsers. Once we switch to that API, use the following code + // to allow requesting permission (clipboard permission) in a non-secure + // context (http). Remaining TODO in this case: search for the equivalent + // flag in Firefox & Electron (if we also want to test them). + // launchOptions.args.push("--unsafely-treat-insecure-origin-as-secure=http://mampf:3000"); + } + + if (browser.family === "firefox") { + // auto open devtools + launchOptions.args.push("-devtools"); + } + + if (browser.name === "electron") { + // auto open devtools + launchOptions.preferences.devTools = true; + } + + return launchOptions; + }); + }, }, }; diff --git a/.config/.rubocop.yml b/.config/.rubocop.yml index cc781ea1b..a3a2b6210 100644 --- a/.config/.rubocop.yml +++ b/.config/.rubocop.yml @@ -110,6 +110,9 @@ Style/MethodCallWithArgsParentheses: Style/RedundantReturn: AllowMultipleReturnValues: true +Style/SafeNavigationChainLength: + Max: 4 + Style/StringLiterals: EnforcedStyle: double_quotes diff --git a/.config/commands/deps.justfile b/.config/commands/deps.justfile new file mode 100644 index 000000000..fb7feed15 --- /dev/null +++ b/.config/commands/deps.justfile @@ -0,0 +1,22 @@ +[private] +help: + @just --list --justfile {{source_file()}} + +# Shows the Dependabot alerts on GitHub +alerts: + #!/usr/bin/env bash + xdg-open https://github.com/MaMpf-HD/mampf/security/dependabot + +# Updates the Bundler package manager itself (NOT the Ruby gems) +update-bundler: + bundle update --bundler + +# Updates Ruby gems +update-gems: + bundle update + +# Updates Node.js packages +update-nodejs: + # You may have to run this command beforehand: + # sudo chown your_user_name -R ./node_modules/ + yarn upgrade diff --git a/.config/commands/docker.justfile b/.config/commands/docker.justfile index 15872ed4b..bec8465f9 100644 --- a/.config/commands/docker.justfile +++ b/.config/commands/docker.justfile @@ -1,4 +1,3 @@ -# Prints this help message [private] help: @just --list --justfile {{source_file()}} @@ -9,6 +8,19 @@ help: cd {{justfile_directory()}}/docker/development/ docker compose up {{args}} +# Starts the dev docker containers (detached) & shows MaMpf logs +up-logs *args: + #!/usr/bin/env bash + cd {{justfile_directory()}}/docker/development/ + docker compose up -d {{args}} + docker compose logs -f mampf + +# Shows the log of the specified container +@logs name="mampf": + #!/usr/bin/env bash + cd {{justfile_directory()}}/docker/development/ + docker compose logs -f {{name}} + # Starts the dev docker containers and preseeds the database [confirm("This will reset all your data in the database locally. Continue? (y/n)")] up-reseed *args: @@ -47,3 +59,22 @@ up-reseed *args: #!/usr/bin/env bash cd {{justfile_directory()}}/docker/development/ docker compose exec mampf bundle exec rails c + +# Rebuilds the most essential containers in the dev or test environment +rebuild env="dev": + #!/usr/bin/env bash + environment={{ if env == "test" {"test"} else {"development"} }} + echo "Rebuilding in env: ${environment}" + cd {{justfile_directory()}}/docker/${environment} + + # Remove + docker compose rm -s mampf + if [ "$environment" = "development" ]; then + docker compose rm -s webpacker + fi + + # Rebuild + docker compose build mampf + if [ "$environment" = "development" ]; then + docker compose build webpacker + fi diff --git a/.config/commands/test.justfile b/.config/commands/test.justfile index c2cacf556..dd66584b9 100644 --- a/.config/commands/test.justfile +++ b/.config/commands/test.justfile @@ -1,4 +1,3 @@ -# Prints this help message [private] help: @just --list --justfile {{source_file()}} diff --git a/.config/commands/utils.justfile b/.config/commands/utils.justfile new file mode 100644 index 000000000..01b6fdfa4 --- /dev/null +++ b/.config/commands/utils.justfile @@ -0,0 +1,70 @@ +[private] +help: + @just --list --justfile {{source_file()}} + +# Generates entity-relationship diagrams (ERD) of the database +erd: + #!/usr/bin/env bash + + # Make sure the mampf dev container is running + cd {{justfile_directory()}}/docker/development/ + if [ -z "$(docker compose ps --services --filter 'status=running' | grep mampf)" ]; then + echo "The mampf dev container is not running. Please start it first (use 'just docker')." + exit 1 + fi + + mkdir -p {{justfile_directory()}}/tmp/erd/ + + # ▶ Generate ERDs + # Customize it with options from here: https://voormedia.github.io/rails-erd/customise.html + # Also see the output from: 'bundle exec erd --help' (inside the dev container) + + # Ignore some tables + ignored_thredded="Thredded::Post,Thredded::UserPostNotification,Thredded::PrivateUser,Thredded::UserPrivateTopicReadState,Thredded::PrivateTopic,Thredded::MessageboardUser,Thredded::PrivatePost,Thredded:UserDetail,Thredded::MessageboardGroup,Thredded::Messageboard,Thredded::Category,Thredded::TopicCategory,Thredded::Topic,Thredded::UserTopicReadState,Thredded::UserTopicFollow,Thredded::NotificationsForFollowedTopics,Thredded::MessageboardNotificationsForFollowedTopics,Thredded::UserPreference,Thredded::UserMessageboardPreference,Thredded::NotificationsForPrivateTopics,Thredded::PostModerationRecord,Thredded::UserDetail" + ignored_translation="Mobility::Backends::ActiveRecord::Table::Translation,Subject::Translation,Program::Translation,Division::Translation" + ignored_commontator="Commontable,Votable,Subscriber,Creator" + other_ignored="ActionMailbox::Record,ActionText::Record,ActiveStorage::Record,Sluggable,FriendlyId::Slug,ApplicationRecord,InteractionsRecord" + exclude_default="${ignored_thredded},${ignored_translation},${ignored_commontator},${other_ignored}" + + # 🌟 Overview with attributes (warnings will be printed only here) + docker compose exec -it mampf rake erd \ + title=false filename=/usr/src/app/tmp/erd/mampf-erd-overview-with-attributes \ + inheritance=false polymorphism=true indirect=false attributes=content \ + exclude="${exclude_default}" + + # 🌟 Generic Overview + docker compose exec -it mampf rake erd warn=false \ + title=false filename=/usr/src/app/tmp/erd/mampf-erd-overview \ + inheritance=false polymorphism=true indirect=false attributes=false \ + exclude="${exclude_default}" + + # 🌟 Vouchers + docker compose exec -it mampf rake erd warn=false \ + title="Vouchers" filename=/usr/src/app/tmp/erd/mampf-erd-vouchers \ + inheritance=true polymorphism=true indirect=true attributes=content \ + exclude="${exclude_default},Teachable,Editable" \ + only="User,Claim,Voucher,Redemption,Lecture,Tutorial,Talk" + + # 🌟 Tutorials + docker compose exec -it mampf rake erd warn=false \ + title="Tutorials" filename=/usr/src/app/tmp/erd/mampf-erd-tutorials \ + inheritance=true polymorphism=true indirect=true attributes=content \ + exclude="${exclude_default},Claimable,Editable,Teachable" \ + only="User,Lecture,Tutorial,Submission,Assignment,TutorTutorialJoin,UserSubmissionJoin" + + # 🌟 Courses + docker compose exec -it mampf rake erd warn=false \ + title="Courses" filename=/usr/src/app/tmp/erd/mampf-erd-courses \ + inheritance=true polymorphism=true indirect=true attributes=content \ + exclude="${exclude_default},Claimable,Editable" \ + only="Subject,Program,Division,DivisionCourseJoin,Course,Lecture,CourseSelfJoin,Lesson" + + # 🌟 Lectures + docker compose exec -it mampf rake erd warn=false \ + title="Lectures" filename=/usr/src/app/tmp/erd/mampf-erd-lectures \ + inheritance=true polymorphism=true indirect=true attributes=content \ + exclude="${exclude_default},Claimable,Editable,Teachable" \ + only="Lecture,Lesson,Chapter,Section,Item,LessonSectionJoin,Term" + + echo "📂 Diagrams are ready for you in the folder {{justfile_directory()}}/tmp/erd/" + echo "🔀 For the meanings of the arrows, refer to https://voormedia.github.io/rails-erd/gallery.html#notations" diff --git a/.gitignore b/.gitignore index fa78b9564..fa5ed4bd3 100644 --- a/.gitignore +++ b/.gitignore @@ -7,9 +7,6 @@ # Ignore bundler config. /.bundle -# Ignore the default SQLite database. -/db/*.sqlite3 -/db/*.sqlite3-journal /db/csv/* /db/backups/* !/backups/.gitkeep diff --git a/.justfile b/.justfile index 5b36d3496..fe8e56d53 100644 --- a/.justfile +++ b/.justfile @@ -1,18 +1,23 @@ # Documentation: https://just.systems/man/en/ -# Prints this help message [private] help: @just --list -# Test-related commands +# Commands to test the MaMpf codebase mod test ".config/commands/test.justfile" # see https://github.com/casey/just/issues/2216 # alias t := test -# Docker-related commands +# Commands to manage the docker containers mod docker ".config/commands/docker.justfile" +# Commands to manage dependencies +mod deps ".config/commands/deps.justfile" + +# Some utils, e.g. ERD-generation etc. +mod utils ".config/commands/utils.justfile" + # Opens the MaMpf wiki in the default browser wiki: #!/usr/bin/env bash diff --git a/.vscode/extensions.json b/.vscode/extensions.json index d98e845e1..1e3544961 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -4,6 +4,7 @@ "dbaeumer.vscode-eslint", "streetsidesoftware.code-spell-checker", "streetsidesoftware.code-spell-checker-german", - "nefrob.vscode-just-syntax" + "nefrob.vscode-just-syntax", + "connorshea.vscode-ruby-test-adapter" ] } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 842ae0e3f..40c9d2aeb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -102,12 +102,33 @@ ////////////////////////////////////// // Spell Checker ////////////////////////////////////// + "cSpell.enabled": true, + "cSpell.ignorePaths": [ + "node_modules", + ".git" + ], + "cSpell.language": "en,de", "cSpell.words": [ + "activerecord", + "ajax", "commontator", + "cospeaker", + "cospeakers", + "datetime", "factorybot", "helpdesk", "katex", + "preselection", + "selectize", + "Timecop", "turbolinks", - "Unsets" - ] + "Unsets", + "uncached", + "whitespaces" + ], + "cSpell.enableFiletypes": [ + "ruby" + // Other filetypes are handled by the default spell checker + ], + "cSpell.maxNumberOfProblems": 10000 } \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dc36806ac..e02c8b8ca 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,41 +1,15 @@ # Contributing -To ensure a smooth experience for contributions, please first open an issue about the change you wish to make or contact -an active developer in some other way before making a change. +We are a small dev team centered around [Denis Vogel](https://www.mathi.uni-heidelberg.de/~vogel/), the creator of MaMpf. He started the project on June 4, 2017 out of frustration with the existing tools and their shortcomings when it comes to teaching mathematics and uploading recorded lectures to the web. He has since been the main developer and maintainer of the project and added tons of functionality throughout the years. MaMpf is now used every day by the mathematics department at Heidelberg University to host their lectures. It is constantly being improved and extended. -## Braches -We have the following branches: -- *production* -- *main* -- *mampf-next* -- feature-branches (names vary) -- *experimental* +Denis Vogel was joined by many students along the way working on the project in the role of a payed HiWis (German abbreviation for "Hilsfwissenschaftler", research assistants) at Heidelberg University. They have contributed to the project in various ways, such as implementing new features, fixing bugs, testing the software and improving the documentation. -### *production* -contains the actual version deployed on [mampf](mampf.mathi.uni-heidelberg.de). +The idea of MaMpf is to provide free material online to the whole world. In that spirit, the source code for MaMpf is open-source and licensed under the very permissive MIT license, so your university can host their own instance of MaMpf if they want to. -### *main* -is usually equal to *production*. Hotfixes are tested here before being merged to *production*. +--- -### *mampf-next* -is the next intended version for mampf. This version is automatically deployed on -[mampf-dev](mampf-dev.mathi.uni-heidelberg.de). Features should be developed in feature branches and merged here. +**While we welcome contributions from everyone, please keep in mind that we are a very small team and currently cannot provide extensive mentoring or guidance to new external contributors.** If you are a HiWi at Heidelberg University, that's of course a different story, but unfortunately, right now we don't have too much time to onboard new developers form the outside. Knowledge transfer is often easier in persona than having to write down everything online. We have done such efforts in our [Wiki](https://github.com/MaMpf-HD/mampf/wiki) and continue to improve it as living document but don't expect it to be fully self-contained. +Also note that due to MaMpf being very specific to our needs at Heidelberg University, we might reject contributions that are not in line with our vision for the project or too general (or too specific to another university) to be useful for us. Therefore, **please open an issue before starting to work on a pull request to discuss your idea with us**. -### feature branches -Collaborators may create a branch for each improvement they would like to integrate in *mampf-next*. If you do not have -collaborator access yet, feel free to instead fork this repository and open a pull request targeted on the *mampf-next* -branch. - -### *experimental* -is used as a playground and for test deployments. Do **not** put important work here. This branch is intended to be -force-pushed by any collaborator. If you ever want to deploy a version in a production-like environment, feel free to -do - -> git checkout experimental -> -> git reset --hard -> -> git push -f - -If you are not a collaborator, feel free to open a pull-request on experimental with a note, that you are aware of this -policy and would just like to try out a change. +> [!tip] +> Check out our [**Wiki**](https://github.com/MaMpf-HD/mampf/wiki) if you are a new MaMpf developer and want to get started with setting up your own local Docker instance and get to know our development workflow and the code structure. diff --git a/Gemfile b/Gemfile index 88a53f4e6..182d74061 100644 --- a/Gemfile +++ b/Gemfile @@ -1,144 +1,103 @@ source "https://rubygems.org" -git_source(:github) { |repo| "https://github.com/#{repo}.git" } +# We only pin versions to specific Git commits when they are "problem childs" +# and we want to review each commit before updating to the latest version. ruby "3.1.4" -# Bundle edge Rails instead: gem 'rails', github: 'rails/rails' -gem "rails", "~> 7.1.3" -# Use dalli for caching to memcached in production -gem "dalli", ">= 2.7" -# Ruby wrapper for UglifyJS JavaScript compressor -gem "terser" -# Use nulldb adapter for assets precompilation in production -gem "activerecord-nulldb-adapter" -# Use sqlite3 as the database for Active Record -gem "sqlite3", "~> 1.4" -# Use Puma as the app server -gem "puma", "< 7" -# Use SCSS for stylesheets -gem "sass-rails", ">= 6" -# Transpile app-like JavaScript. Read more: https://github.com/rails/webpacker -# gem 'webpacker', '~> 4.0' -# Turbolinks makes navigating your web application faster. Read more: https://github.com/turbolinks/turbolinks -gem "turbolinks", "~> 5" -# Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder -gem "jbuilder" -# Use Redis adapter to run Action Cable in production -# gem 'redis', '~> 4.0' -# Use Active Model has_secure_password -# gem 'bcrypt', '~> 3.1.7' - -# Use Active Storage variant -# gem 'image_processing', '~> 1.2' - -# Reduces boot times through caching; required in config/boot.rb -gem "active_model_serializers" -gem "bootsnap", ">= 1.4.2", require: false -gem "rack", "<3" -# Use CoffeeScript for .coffee assets and views -gem "coffee-rails", "~> 5.0.0" - -# Use Redis adapter to run Action Cable in production -# gem 'redis', '~> 3.0' -gem "fastimage" -gem "image_processing" -gem "mini_magick" -gem "pdf-reader" -gem "shrine" -gem "streamio-ffmpeg" -# Use ActiveModel has_secure_password -# gem 'bcrypt', '~> 3.1.7' -gem "filesize" -# Use Capistrano for deployment -# gem 'capistrano-rails', group: :development -gem "activerecord-import", - git: "https://github.com/zdennis/activerecord-import.git", - branch: "master" -gem "acts_as_list" -gem "acts_as_tree" -gem "acts_as_votable" -gem "barby" -gem "bootstrap", "~>5" -gem "bootstrap_form" -gem "cancancan" -gem "clipboard-rails" -gem "commontator" -gem "coveralls", require: false -gem "devise" -gem "devise-bootstrap-views" -gem "erubis" -gem "exception_handler", "~> 0.8.0.0" -gem "faraday", "~> 1.8" -gem "fuzzy-string-match" -gem "jquery-rails" -gem "jquery-ui-rails" +gem "active_model_serializers", "~> 0.10" +gem "activerecord-import", "~>1.7" +gem "activerecord-nulldb-adapter", "~> 1.0" # for assets precompilation in production +gem "acts_as_list", "~> 1.2" +gem "acts_as_tree", "~> 2.9" +gem "acts_as_votable", "~> 0.14" +gem "barby", "~> 0.6" +gem "bootsnap", "~> 1.18", require: false # reduces boot times through caching +gem "bootstrap", "~>5.3" +gem "bootstrap_form", "~> 5.4" +gem "cancancan", "~> 3.6" +gem "clipboard-rails", "~> 1.7" +gem "coffee-rails", "~> 5.0" # CoffeeScript for .coffee assets and views +gem "commontator", "~> 7.0.1" +gem "coveralls", "~> 0.7", require: false +gem "dalli", "~> 3.2" # caching to memcached in production +gem "devise", "~> 4.9" +gem "devise-bootstrap-views", "~> 1.1" +gem "erubis", "~> 2.7" +gem "exception_handler", "~> 0.8.0.0", "~> 0.8.0" +gem "faraday", "~> 1.8", "~> 1.10" +gem "fastimage", "~> 2.3" +gem "filesize", "~> 0.2" +gem "fuzzy-string-match", "~> 1.0" +gem "image_processing", "~> 1.13" +gem "jbuilder", "~> 2.12" # build JSON APIs easily +gem "jquery-rails", "~> 4.6" +gem "jquery-ui-rails", "~> 7.0" gem "js-routes", "1.4.9" -gem "kaminari" -gem "kaminari-i18n" -gem "kramdown-parser-gfm" -gem "mobility" -gem "net-smtp" -gem "pg" -gem "premailer-rails" -gem "progress_bar" -gem "rails-i18n" -gem "responders" -gem "rgl" -gem "rqrcode" -gem "rubyzip", "~> 2.3.0" -gem "sidekiq" -gem "sidekiq-cron", "~> 1.1" -gem "sprockets-rails", - git: "https://github.com/rails/sprockets-rails", - branch: "master" -gem "sunspot_rails", - github: "sunspot/sunspot", - glob: "sunspot_rails/*.gemspec" -gem "sunspot_solr" +gem "kaminari", "~> 1.2" +gem "kaminari-i18n", "~> 0.5" +gem "kramdown-parser-gfm", "~> 1.1" +gem "mini_magick", "~> 4.13" +gem "mobility", "~> 1.2" +gem "net-smtp", "~> 0.5" +gem "pdf-reader", "~> 2.12" +gem "pg", "~> 1.5" +gem "premailer-rails", "~> 1.12" +gem "progress_bar", "~> 1.3" +gem "prometheus_exporter", "~> 2.1" +gem "puma", "~> 6.4" # app server +gem "rack", "~> 2.2" +gem "rails", "~> 7.1.3" +gem "rails-i18n", "~> 7.0" +gem "responders", "~> 3.1" +gem "rgl", "~> 0.6" +gem "rqrcode", "~> 2.2" +gem "rubyzip", "~> 2.3" +gem "sass-rails", "~> 6.0" # SCSS for stylesheets +gem "shrine", "~> 3.6" +gem "sidekiq", "~> 7.3" +gem "sidekiq-cron", "~> 1.12" +gem "sprockets-rails", "~>3.5" +gem "streamio-ffmpeg", "~> 3.0" +gem "sunspot_rails", "~> 2.7" +gem "sunspot_solr", "~> 2.7" +gem "terser", "~> 1.2" # Ruby wrapper for UglifyJS JavaScript compressor gem "thredded", git: "https://github.com/thredded/thredded.git", ref: "1340e913affd1af5fcc060fbccd271184ece9a6a" gem "thredded-markdown_katex", git: "https://github.com/thredded/thredded-markdown_katex.git", - branch: "main" -gem "trix-rails", require: "trix" -gem "webpacker", "~> 5.x" + ref: "e2830bdb40880018a0e59d2b82c94b0a9f237365" +gem "trix-rails", "~> 2.4", require: "trix" +gem "turbolinks", "~> 5.2" # make navigating the app faster +gem "webpacker", "~> 5.4" group :development, :docker_development do gem "listen", "~> 3.9" - gem "rails-erd" - # Access an interactive console on exception pages or by calling 'console' anywhere in the code. - gem "web-console", ">= 3.3.0" - # Spring speeds up development by keeping your application running in the background. Read more: https://github.com/rails/spring - gem "marcel" - gem "pgreset" - gem "rubocop", "~> 1.63", require: false + gem "marcel", "~> 1.0" + gem "pgreset", "~> 0.4" + gem "rails-erd", "~> 1.7" + gem "rubocop", "~> 1.65", require: false gem "rubocop-performance", "~> 1.21", require: false gem "rubocop-rails", "~> 2.24", require: false - gem "spring" - gem "spring-watcher-listen", "~> 2.0.0" - # gem 'bullet' + gem "spring", "~> 2.1" # app preloader, keeps app running in background for development + gem "spring-watcher-listen", "~> 2.0" + gem "web-console", "~> 4.2" # interactive console on exception pages end group :test do - # Adds support for Capybara system testing and selenium driver - gem "selenium-webdriver" - # Easy installation and use of web drivers to run system tests with browsers - gem "database_cleaner-active_record" - gem "faker" - gem "launchy" - gem "simplecov", require: false - gem "webdrivers" + gem "database_cleaner-active_record", "~> 2.2" # clean up database between tests + gem "faker", "~> 3.4" + gem "launchy", "~> 3.0" + gem "selenium-webdriver", "~> 4.10.0" # support for Capybara system testing and selenium driver + gem "simplecov", "~> 0.22", require: false + gem "timecop", "~> 0.9.10" + gem "webdrivers", "~> 5.3" end group :test, :development, :docker_development do # Call 'byebug' anywhere in the code to stop execution and get a debugger console - gem "byebug", platforms: [:mri, :mingw, :x64_mingw] - gem "factory_bot_rails" - gem "rspec-rails" - - gem "simplecov-cobertura" - - gem "rspec-github" + gem "byebug", "~> 11.1", platforms: [:mri, :mingw, :x64_mingw] + gem "factory_bot_rails", "~> 6.4" + gem "rspec-github", "~> 2.4" + gem "rspec-rails", "~> 6.1" + gem "simplecov-cobertura", "~> 2.1" end - -gem "prometheus_exporter" diff --git a/Gemfile.lock b/Gemfile.lock index 9cd99b59f..4bd07aa0e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,26 +1,7 @@ -GIT - remote: https://github.com/rails/sprockets-rails - revision: 2c04236faaacd021b7810289cbac93e962ff14da - branch: master - specs: - sprockets-rails (3.5.2) - actionpack (>= 6.1) - activesupport (>= 6.1) - sprockets (>= 3.0.0) - -GIT - remote: https://github.com/sunspot/sunspot.git - revision: 2cb3e49c6e9c8ec23b8d95f9dcf2d28d1248d61b - glob: sunspot_rails/*.gemspec - specs: - sunspot_rails (2.7.1) - rails (>= 5) - sunspot (= 2.7.1) - GIT remote: https://github.com/thredded/thredded-markdown_katex.git revision: e2830bdb40880018a0e59d2b82c94b0a9f237365 - branch: main + ref: e2830bdb40880018a0e59d2b82c94b0a9f237365 specs: thredded-markdown_katex (1.0.0) katex (>= 0.4.3) @@ -53,50 +34,42 @@ GIT sprockets-es6 timeago_js (>= 3.0.2.2) -GIT - remote: https://github.com/zdennis/activerecord-import.git - revision: fca8b823ae695b03714837cc6603f51525c60505 - branch: master - specs: - activerecord-import (1.7.0) - activerecord (>= 4.2) - GEM remote: https://rubygems.org/ specs: - Ascii85 (1.1.1) + Ascii85 (2.0.1) RubyInline (3.14.1) ZenTest (~> 4.3) ZenTest (4.12.2) - actioncable (7.1.3.4) - actionpack (= 7.1.3.4) - activesupport (= 7.1.3.4) + actioncable (7.1.5) + actionpack (= 7.1.5) + activesupport (= 7.1.5) 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) + actionmailbox (7.1.5) + actionpack (= 7.1.5) + activejob (= 7.1.5) + activerecord (= 7.1.5) + activestorage (= 7.1.5) + activesupport (= 7.1.5) 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) + actionmailer (7.1.5) + actionpack (= 7.1.5) + actionview (= 7.1.5) + activejob (= 7.1.5) + activesupport (= 7.1.5) mail (~> 2.5, >= 2.5.4) net-imap net-pop net-smtp rails-dom-testing (~> 2.2) - actionpack (7.1.3.4) - actionview (= 7.1.3.4) - activesupport (= 7.1.3.4) + actionpack (7.1.5) + actionview (= 7.1.5) + activesupport (= 7.1.5) nokogiri (>= 1.8.5) racc rack (>= 2.2.4) @@ -104,54 +77,59 @@ GEM 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) + actiontext (7.1.5) + actionpack (= 7.1.5) + activerecord (= 7.1.5) + activestorage (= 7.1.5) + activesupport (= 7.1.5) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.1.3.4) - activesupport (= 7.1.3.4) + actionview (7.1.5) + activesupport (= 7.1.5) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - active_model_serializers (0.10.14) + active_model_serializers (0.10.15) actionpack (>= 4.1) activemodel (>= 4.1) case_transform (>= 0.2) jsonapi-renderer (>= 0.1.1.beta1, < 0.3) active_record_union (1.3.0) activerecord (>= 4.0) - activejob (7.1.3.4) - activesupport (= 7.1.3.4) + activejob (7.1.5) + activesupport (= 7.1.5) 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 (7.1.5) + activesupport (= 7.1.5) + activerecord (7.1.5) + activemodel (= 7.1.5) + activesupport (= 7.1.5) timeout (>= 0.4.0) - activerecord-nulldb-adapter (1.0.1) - activerecord (>= 5.2.0, < 7.2) - 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) + activerecord-import (1.8.1) + activerecord (>= 4.2) + activerecord-nulldb-adapter (1.1.1) + activerecord (>= 6.0, < 8.1) + activestorage (7.1.5) + actionpack (= 7.1.5) + activejob (= 7.1.5) + activerecord (= 7.1.5) + activesupport (= 7.1.5) marcel (~> 1.0) - activesupport (7.1.3.4) + activesupport (7.1.5) base64 + benchmark (>= 0.3) bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) + logger (>= 1.4.2) minitest (>= 5.1) mutex_m + securerandom (>= 0.3) tzinfo (~> 2.0) - acts_as_list (1.2.2) + acts_as_list (1.2.4) activerecord (>= 6.1) activesupport (>= 6.1) acts_as_tree (2.9.1) @@ -167,9 +145,10 @@ GEM babel-transpiler (0.7.0) babel-source (>= 4.0, < 6) execjs (~> 2.0) - barby (0.6.9) + barby (0.7.0) base64 (0.2.0) bcrypt (3.1.20) + benchmark (0.4.0) bigdecimal (3.1.8) bindex (0.8.1) bootsnap (1.18.4) @@ -211,14 +190,14 @@ GEM term-ansicolor thor crass (1.0.6) - css_parser (1.17.1) + css_parser (1.19.1) addressable dalli (3.2.8) database_cleaner-active_record (2.2.0) activerecord (>= 5.a) database_cleaner-core (~> 2.0.0) database_cleaner-core (2.0.1) - date (3.3.4) + date (3.4.0) db_text_search (1.0.0) activerecord (>= 4.1.15) devise (4.9.4) @@ -242,15 +221,15 @@ GEM bundler rails (>= 4.2.0) responders - execjs (2.9.1) - factory_bot (6.4.6) + execjs (2.10.0) + factory_bot (6.5.0) activesupport (>= 5.0.0) - factory_bot_rails (6.4.3) - factory_bot (~> 6.4) + factory_bot_rails (6.4.4) + factory_bot (~> 6.5) railties (>= 5.0.0) - faker (3.4.2) + faker (3.5.1) i18n (>= 1.8.11, < 2) - faraday (1.10.3) + faraday (1.10.4) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) @@ -278,7 +257,7 @@ GEM filesize (0.2.0) friendly_id (5.5.1) activerecord (>= 4.0.0) - fugit (1.11.0) + fugit (1.11.1) et-orbi (~> 1, >= 1.2.11) raabro (~> 1.4) fuzzy-string-match (1.0.1) @@ -286,28 +265,28 @@ GEM globalid (1.2.1) activesupport (>= 6.1) hashery (2.1.2) - highline (3.1.0) + highline (3.1.1) reline html-pipeline (2.14.3) activesupport (>= 2) nokogiri (>= 1.4) htmlentities (4.3.4) http-accept (1.7.0) - http-cookie (1.0.6) + http-cookie (1.0.7) domain_name (~> 0.5) - i18n (1.14.5) + i18n (1.14.6) concurrent-ruby (~> 1.0) image_processing (1.13.0) mini_magick (>= 4.9.5, < 5) ruby-vips (>= 2.0.17, < 3) - inline_svg (1.9.0) + inline_svg (1.10.0) activesupport (>= 3.0) nokogiri (>= 1.6) io-console (0.7.2) - irb (1.14.0) + irb (1.14.1) rdoc (>= 4.0.0) reline (>= 0.4.2) - jbuilder (2.12.0) + jbuilder (2.13.0) actionview (>= 5.0.0) activesupport (>= 5.0.0) jquery-rails (4.6.0) @@ -319,7 +298,7 @@ GEM js-routes (1.4.9) railties (>= 4) sprockets-rails - json (2.7.2) + json (2.8.2) jsonapi-renderer (0.2.2) kaminari (1.2.2) activesupport (>= 4.1.0) @@ -338,8 +317,8 @@ GEM rails katex (0.10.0) execjs (~> 2.8) - kramdown (2.4.0) - rexml + kramdown (2.5.1) + rexml (>= 3.3.9) kramdown-math-katex (1.0.1) katex (~> 0.4) kramdown (~> 2.0) @@ -352,8 +331,8 @@ GEM listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - logger (1.6.0) - loofah (2.22.0) + logger (1.6.1) + loofah (2.23.1) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.8.1) @@ -362,21 +341,22 @@ GEM net-pop net-smtp marcel (1.0.4) - mime-types (3.5.2) + mime-types (3.6.0) + logger mime-types-data (~> 3.2015) - mime-types-data (3.2024.0806) + mime-types-data (3.2024.1105) mini_magick (4.13.2) mini_mime (1.1.5) - minitest (5.24.1) - mobility (1.2.9) + minitest (5.25.2) + mobility (1.3.1) i18n (>= 0.6.10, < 2) request_store (~> 1.0) - msgpack (1.7.2) + msgpack (1.7.5) multi_json (1.15.0) multipart-post (2.4.1) mustache (1.1.1) - mutex_m (0.2.0) - net-imap (0.4.14) + mutex_m (0.3.0) + net-imap (0.5.1) date net-protocol net-pop (0.1.2) @@ -386,7 +366,7 @@ GEM net-smtp (0.5.0) net-protocol netrc (0.11.0) - nio4r (2.7.3) + nio4r (2.7.4) nokogiri (1.16.7-x86_64-linux) racc (~> 1.4) onebox (2.2.19) @@ -399,23 +379,23 @@ GEM options (2.3.2) orm_adapter (0.5.0) pairing_heap (3.1.0) - parallel (1.26.2) - parser (3.3.4.2) + parallel (1.26.3) + parser (3.3.6.0) ast (~> 2.4.1) racc - pdf-reader (2.12.0) - Ascii85 (~> 1.0) + pdf-reader (2.13.0) + Ascii85 (>= 1.0, < 3.0, != 2.0.0) afm (~> 0.2.1) hashery (~> 2.0) ruby-rc4 ttfunk - pg (1.5.7) + pg (1.5.9) pgreset (0.4) popper_js (2.11.8) pr_geohash (1.0.0) - premailer (1.23.0) + premailer (1.27.0) addressable - css_parser (>= 1.12.0) + css_parser (>= 1.19.0) htmlentities (>= 4.0.0) premailer-rails (1.12.0) actionmailer (>= 3) @@ -426,39 +406,39 @@ GEM options (~> 2.3.0) prometheus_exporter (2.1.1) webrick - psych (5.1.2) + psych (5.2.0) stringio public_suffix (6.0.1) - puma (6.4.2) + puma (6.5.0) nio4r (~> 2.0) - pundit (2.3.2) + pundit (2.4.0) activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) - rack (2.2.9) + rack (2.2.10) rack-proxy (0.7.7) rack rack-session (1.0.2) rack (< 3) rack-test (2.1.0) rack (>= 1.3) - rackup (1.0.0) + rackup (1.0.1) rack (< 3) webrick - 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 (7.1.5) + actioncable (= 7.1.5) + actionmailbox (= 7.1.5) + actionmailer (= 7.1.5) + actionpack (= 7.1.5) + actiontext (= 7.1.5) + actionview (= 7.1.5) + activejob (= 7.1.5) + activemodel (= 7.1.5) + activerecord (= 7.1.5) + activestorage (= 7.1.5) + activesupport (= 7.1.5) bundler (>= 1.15.0) - railties (= 7.1.3.4) + railties (= 7.1.5) rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest @@ -471,14 +451,14 @@ GEM rails-html-sanitizer (1.6.0) loofah (~> 2.21) nokogiri (~> 1.14) - rails-i18n (7.0.9) + rails-i18n (7.0.10) i18n (>= 0.7, < 2) railties (>= 6.0.0, < 8) rails_gravatar (1.0.4) actionview - railties (7.1.3.4) - actionpack (= 7.1.3.4) - activesupport (= 7.1.3.4) + railties (7.1.5) + actionpack (= 7.1.5) + activesupport (= 7.1.5) irb rackup (>= 1.0.0) rake (>= 12.2) @@ -489,12 +469,12 @@ GEM rb-fsevent (0.11.2) rb-inotify (0.11.1) ffi (~> 1.0) - rdoc (6.7.0) + rdoc (6.8.1) psych (>= 4.0.0) redis-client (0.22.2) connection_pool - regexp_parser (2.9.2) - reline (0.5.9) + regexp_parser (2.9.3) + reline (0.5.12) io-console (~> 0.5) request_store (1.7.0) rack (>= 1.4) @@ -506,8 +486,7 @@ GEM http-cookie (>= 1.0.2, < 2.0) mime-types (>= 1.16, < 4.0) netrc (~> 0.8) - rexml (3.3.5) - strscan + rexml (3.3.9) rgl (0.6.6) pairing_heap (>= 0.3, < 4.0) rexml (~> 3.2, >= 3.2.4) @@ -520,17 +499,17 @@ GEM rsolr (2.6.0) builder (>= 2.1.2) faraday (>= 0.9, < 3, != 2.0.0) - rspec-core (3.13.0) + rspec-core (3.13.2) rspec-support (~> 3.13.0) - rspec-expectations (3.13.1) + rspec-expectations (3.13.3) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-github (2.4.0) rspec-core (~> 3.0) - rspec-mocks (3.13.1) + rspec-mocks (3.13.2) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-rails (6.1.3) + rspec-rails (6.1.5) actionpack (>= 6.1) activesupport (>= 6.1) railties (>= 6.1) @@ -539,26 +518,25 @@ GEM rspec-mocks (~> 3.13) rspec-support (~> 3.13) rspec-support (3.13.1) - rubocop (1.65.1) + rubocop (1.69.0) json (~> 2.3) language_server-protocol (>= 3.17.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) + rubocop-ast (>= 1.36.1, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.32.0) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.36.2) parser (>= 3.3.1.0) - rubocop-performance (1.21.1) + rubocop-performance (1.23.0) rubocop (>= 1.48.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rails (2.25.1) + rubocop-rails (2.27.0) activesupport (>= 4.2.0) rack (>= 1.1) - rubocop (>= 1.33.0, < 2.0) + rubocop (>= 1.52.0, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) ruby-graphviz (1.2.5) rexml @@ -569,7 +547,7 @@ GEM logger ruby2_keywords (0.0.5) rubyzip (2.3.2) - sanitize (6.1.2) + sanitize (6.1.3) crass (~> 1.0.2) nokogiri (>= 1.12.0) sass-rails (6.0.0) @@ -582,16 +560,16 @@ GEM sprockets (> 3.0) sprockets-rails tilt + securerandom (0.3.2) selenium-webdriver (4.10.0) rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) - semantic_range (3.0.0) + semantic_range (3.1.0) shrine (3.6.0) content_disposition (~> 1.0) down (~> 5.1) - sidekiq (7.3.0) - concurrent-ruby (< 2) + sidekiq (7.3.6) connection_pool (>= 2.3.0) logger rack (>= 2.2.4) @@ -607,7 +585,7 @@ GEM simplecov-cobertura (2.1.0) rexml simplecov (~> 0.19) - simplecov-html (0.12.3) + simplecov-html (0.13.1) simplecov_json_formatter (0.1.4) spring (2.1.1) spring-watcher-listen (2.0.1) @@ -620,27 +598,33 @@ GEM babel-source (>= 5.8.11) babel-transpiler sprockets (>= 3.0.0) - sqlite3 (1.7.3-x86_64-linux) + sprockets-rails (3.5.2) + actionpack (>= 6.1) + activesupport (>= 6.1) + sprockets (>= 3.0.0) stream (0.5.5) streamio-ffmpeg (3.0.2) multi_json (~> 1.8) - stringio (3.1.1) - strscan (3.1.0) + stringio (3.1.2) sunspot (2.7.1) bigdecimal pr_geohash (~> 1.0) rsolr (>= 1.1.1, < 3) + sunspot_rails (2.7.1) + rails (>= 5) + sunspot (= 2.7.1) sunspot_solr (2.7.1) sync (0.5.0) term-ansicolor (1.11.2) tins (~> 1.0) - terser (1.2.3) + terser (1.2.4) execjs (>= 0.3.0, < 3) - thor (1.3.1) + thor (1.3.2) tilt (2.4.0) timeago_js (3.0.2.2) - timeout (0.4.1) - tins (1.33.0) + timecop (0.9.10) + timeout (0.4.2) + tins (1.37.0) bigdecimal sync trix-rails (2.4.0) @@ -652,7 +636,9 @@ GEM turbolinks-source (5.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - unicode-display_width (2.5.0) + unicode-display_width (3.1.2) + unicode-emoji (~> 4.0, >= 4.0.4) + unicode-emoji (4.0.4) warden (1.2.9) rack (>= 2.0.9) web-console (4.2.1) @@ -669,105 +655,105 @@ GEM rack-proxy (>= 0.6.1) railties (>= 5.2) semantic_range (>= 2.3.0) - webrick (1.8.1) + webrick (1.9.0) websocket (1.2.11) websocket-driver (0.7.6) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.5) will_paginate (4.0.1) - zeitwerk (2.6.17) + zeitwerk (2.6.18) PLATFORMS x86_64-linux DEPENDENCIES - active_model_serializers - activerecord-import! - activerecord-nulldb-adapter - acts_as_list - acts_as_tree - acts_as_votable - barby - bootsnap (>= 1.4.2) - bootstrap (~> 5) - bootstrap_form - byebug - cancancan - clipboard-rails - coffee-rails (~> 5.0.0) - commontator - coveralls - dalli (>= 2.7) - database_cleaner-active_record - devise - devise-bootstrap-views - erubis - exception_handler (~> 0.8.0.0) - factory_bot_rails - faker - faraday (~> 1.8) - fastimage - filesize - fuzzy-string-match - image_processing - jbuilder - jquery-rails - jquery-ui-rails + active_model_serializers (~> 0.10) + activerecord-import (~> 1.7) + activerecord-nulldb-adapter (~> 1.0) + acts_as_list (~> 1.2) + acts_as_tree (~> 2.9) + acts_as_votable (~> 0.14) + barby (~> 0.6) + bootsnap (~> 1.18) + bootstrap (~> 5.3) + bootstrap_form (~> 5.4) + byebug (~> 11.1) + cancancan (~> 3.6) + clipboard-rails (~> 1.7) + coffee-rails (~> 5.0) + commontator (~> 7.0.1) + coveralls (~> 0.7) + dalli (~> 3.2) + database_cleaner-active_record (~> 2.2) + devise (~> 4.9) + devise-bootstrap-views (~> 1.1) + erubis (~> 2.7) + exception_handler (~> 0.8.0.0, ~> 0.8.0) + factory_bot_rails (~> 6.4) + faker (~> 3.4) + faraday (~> 1.8, ~> 1.10) + fastimage (~> 2.3) + filesize (~> 0.2) + fuzzy-string-match (~> 1.0) + image_processing (~> 1.13) + jbuilder (~> 2.12) + jquery-rails (~> 4.6) + jquery-ui-rails (~> 7.0) js-routes (= 1.4.9) - kaminari - kaminari-i18n - kramdown-parser-gfm - launchy + kaminari (~> 1.2) + kaminari-i18n (~> 0.5) + kramdown-parser-gfm (~> 1.1) + launchy (~> 3.0) listen (~> 3.9) - marcel - mini_magick - mobility - net-smtp - pdf-reader - pg - pgreset - premailer-rails - progress_bar - prometheus_exporter - puma (< 7) - rack (< 3) + marcel (~> 1.0) + mini_magick (~> 4.13) + mobility (~> 1.2) + net-smtp (~> 0.5) + pdf-reader (~> 2.12) + pg (~> 1.5) + pgreset (~> 0.4) + premailer-rails (~> 1.12) + progress_bar (~> 1.3) + prometheus_exporter (~> 2.1) + puma (~> 6.4) + rack (~> 2.2) rails (~> 7.1.3) - rails-erd - rails-i18n - responders - rgl - rqrcode - rspec-github - rspec-rails - rubocop (~> 1.63) + rails-erd (~> 1.7) + rails-i18n (~> 7.0) + responders (~> 3.1) + rgl (~> 0.6) + rqrcode (~> 2.2) + rspec-github (~> 2.4) + rspec-rails (~> 6.1) + rubocop (~> 1.65) rubocop-performance (~> 1.21) rubocop-rails (~> 2.24) - rubyzip (~> 2.3.0) - sass-rails (>= 6) - selenium-webdriver - shrine - sidekiq - sidekiq-cron (~> 1.1) - simplecov - simplecov-cobertura - spring - spring-watcher-listen (~> 2.0.0) - sprockets-rails! - sqlite3 (~> 1.4) - streamio-ffmpeg - sunspot_rails! - sunspot_solr - terser + rubyzip (~> 2.3) + sass-rails (~> 6.0) + selenium-webdriver (~> 4.10.0) + shrine (~> 3.6) + sidekiq (~> 7.3) + sidekiq-cron (~> 1.12) + simplecov (~> 0.22) + simplecov-cobertura (~> 2.1) + spring (~> 2.1) + spring-watcher-listen (~> 2.0) + sprockets-rails (~> 3.5) + streamio-ffmpeg (~> 3.0) + sunspot_rails (~> 2.7) + sunspot_solr (~> 2.7) + terser (~> 1.2) thredded! thredded-markdown_katex! - trix-rails - turbolinks (~> 5) - web-console (>= 3.3.0) - webdrivers - webpacker (~> 5.x) + timecop (~> 0.9.10) + trix-rails (~> 2.4) + turbolinks (~> 5.2) + web-console (~> 4.2) + webdrivers (~> 5.3) + webpacker (~> 5.4) RUBY VERSION ruby 3.1.4p223 BUNDLED WITH - 2.5.9 + 2.5.17 diff --git a/LICENSE b/LICENSE index 8e20f5503..5e079cec0 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2017 Denis Vogel +Copyright (c) 2017-2024 Denis Vogel & Contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/SECURITY.md b/SECURITY.md index 7162e6d8c..8db2e865a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,12 +1,8 @@ # Security Policy -## Supported Versions +> [!warning] +> MaMpf is continuously deployed, so users will always get to work with the newest version.
That's why we will only release patches for the `main` branch. -We only release patches for the production branch. - -## Reporting a Vulnerability - -Please report (suspected) security vulnerabilities to -mampf-security@mathi.uni-heidelberg.de. You will receive a response from us -within 48 hours. If the issue is confirmed, we will release a patch as soon -as possible depending on complexity but usually within a few days. +- We are very grateful for any reports of security vulnerabilities in MaMpf. We take security very seriously and will respond to verified reports as soon as possible. +- Please don't report vulnerabilities in the public GitHub issue tracker. Instead, [**report them here privately on GitHub**](https://github.com/MaMpf-HD/mampf/security) and do NOT disclose them publicly until we have had a chance to address them. +- Note that we don't give out bounties for security vulnerabilities. We are a non-profit project and don't have the resources to pay for security reports. We are grateful for any reports and will acknowledge them in our release notes. diff --git a/app/abilities/user_ability.rb b/app/abilities/user_ability.rb index 3cfabfd8a..725d2d52b 100644 --- a/app/abilities/user_ability.rb +++ b/app/abilities/user_ability.rb @@ -14,11 +14,7 @@ def initialize(user) user.admin? || (!user.generic? && user == given_user) end - can :fill_user_select, User do - user.active_teachable_editor? - end - - can :list_generic_users, User do + can [:fill_user_select, :list_generic_users], User do user.admin? end end diff --git a/app/abilities/voucher_ability.rb b/app/abilities/voucher_ability.rb new file mode 100644 index 000000000..9f491d4d4 --- /dev/null +++ b/app/abilities/voucher_ability.rb @@ -0,0 +1,13 @@ +class VoucherAbility + include CanCan::Ability + + def initialize(user) + clear_aliased_actions + + can [:create, :invalidate], Voucher do |voucher| + user.can_update_personell?(voucher.lecture) + end + + can [:verify, :redeem, :cancel], Voucher + end +end diff --git a/app/assets/images/voronoi_pattern.svg b/app/assets/images/voronoi_pattern.svg new file mode 100644 index 000000000..e2b2e05cf --- /dev/null +++ b/app/assets/images/voronoi_pattern.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/app/assets/javascripts/_selectize_turbolinks_fix.js b/app/assets/javascripts/_selectize_turbolinks_fix.js index dfe418cf2..f8ac4666c 100644 --- a/app/assets/javascripts/_selectize_turbolinks_fix.js +++ b/app/assets/javascripts/_selectize_turbolinks_fix.js @@ -127,9 +127,21 @@ function fillOptionsByAjax($selectizedSelection) { })(); } else { + let renderOptions = {}; + + let noResultsMessage = this.dataset.noResults; + if (noResultsMessage) { + renderOptions = { + no_results: function (_data, _escape) { + return '
' + noResultsMessage + "
"; + }, + }; + } + return new TomSelect("#" + this.id, { plugins: plugins, maxOptions: null, + render: renderOptions, }); } }); diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index b6faa2615..55a32e06e 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -27,6 +27,7 @@ //= require bootstrap_popovers //= require chapters //= require clickers +//= require copy_and_paste_button //= require courses //= require erdbeere //= require file_upload @@ -38,7 +39,6 @@ //= require masonry_grid //= require media //= require notifications -//= require profile //= require questions //= require quizzes //= require referrals diff --git a/app/assets/javascripts/copy_and_paste_button.js b/app/assets/javascripts/copy_and_paste_button.js new file mode 100644 index 000000000..fe0e41bc3 --- /dev/null +++ b/app/assets/javascripts/copy_and_paste_button.js @@ -0,0 +1,32 @@ +$(document).on("turbolinks:load", function () { + // TODO: this is using clipboard.js, which makes use of deprecated browser APIs + // see issue #684 + new Clipboard(".clipboard-btn"); + + $(document).on("click", ".clipboard-button", function () { + $(".token-clipboard-popup").removeClass("show"); + + let dataId = $(this).data("id"); + let popup; + if (dataId) { + popup = `.token-clipboard-popup[data-id="${$(this).data("id")}"]`; + } + else { + // This is a workaround for the transition to the new ClipboardAPI + // as intermediate solution that respects that the whole button should + // be clickable, not just the icon itself. + // See app/views/vouchers/_voucher.html.erb as an example. + popup = $(this).find(".token-clipboard-popup"); + } + + $(popup).addClass("show"); + setTimeout(() => { + $(popup).removeClass("show"); + }, 1700); + }); +}); + +// clean up for turbolinks +$(document).on("turbolinks:before-cache", function () { + $(document).off("click", ".clipboard-button"); +}); diff --git a/app/assets/javascripts/profile.coffee b/app/assets/javascripts/profile.coffee deleted file mode 100644 index 922b15b38..000000000 --- a/app/assets/javascripts/profile.coffee +++ /dev/null @@ -1,37 +0,0 @@ -# Place all the behaviors and hooks related to the matching controller here. -# All this logic will automatically be available in application.js. -# You can use CoffeeScript in this file: http://coffeescript.org/ - -$(document).on 'turbolinks:load', -> - - $('#profileForm').on 'change', -> - console.log 'Änderung' - $('#profileChange').show() - return - - $('input:checkbox[name^="user[lecture"]').on 'change', -> - courseId = this.dataset.course - lectureId = this.dataset.lecture - checkedCount = $('input:checked[data-course="'+courseId+'"]').length - authRequiredLectureIds = $('#lectures-for-course-' + courseId).data('authorize') - if $(this).prop('checked') and parseInt(lectureId) in authRequiredLectureIds - $('#pass-lecture-' + lectureId).show() - else - $('#pass-lecture-' + lectureId).hide() - if checkedCount == 0 - $('.courseSubInfo[data-course="'+courseId+'"]').removeClass('fas fa-check-circle') - .addClass('far fa-circle') - else - $('.courseSubInfo[data-course="'+courseId+'"]').removeClass('far fa-circle') - .addClass('fas fa-check-circle') - return - - $('.programCollapse').on 'show.bs.collapse', -> - program = $(this).data('program') - $('#program-' + program + '-collapse').find('.coursePlaceholder').each -> - course = $(this).data('course') - $(this).append($('#course-card-' + course)) - $('#course-card-' + course).show() - return - - return \ No newline at end of file diff --git a/app/assets/javascripts/profile.js b/app/assets/javascripts/profile.js new file mode 100644 index 000000000..d02c5d378 --- /dev/null +++ b/app/assets/javascripts/profile.js @@ -0,0 +1,42 @@ +$(document).ready(function () { + $("#profileForm").on("change input", function () { + $("#profileChange").removeClass("d-none"); + $("#profileChangeBottom").removeClass("d-none"); + }); + + $('input:checkbox[name^="user[lecture"]').on("change", function () { + const courseId = this.dataset.course; + const lectureId = this.dataset.lecture; + const checkedCount = $('input:checked[data-course="' + courseId + '"]').length; + const authRequiredLectureIds = $("#lectures-for-course-" + courseId).data("authorize"); + + if ($(this).prop("checked") && authRequiredLectureIds.includes(parseInt(lectureId))) { + $("#pass-lecture-" + lectureId).show(); + } + else { + $("#pass-lecture-" + lectureId).hide(); + if (checkedCount === 0) { + $('.courseSubInfo[data-course="' + courseId + '"]').removeClass("fas fa-check-circle") + .addClass("far fa-circle"); + } + else { + $('.courseSubInfo[data-course="' + courseId + '"]').removeClass("far fa-circle") + .addClass("fas fa-check-circle"); + } + } + }); + + $(".programCollapse").on("show.bs.collapse", function () { + const program = $(this).data("program"); + $("#program-" + program + "-collapse").find(".coursePlaceholder").each(function () { + const course = $(this).data("course"); + $(this).append($("#course-card-" + course)); + $("#course-card-" + course).show(); + }); + }); + + $("#request-data-btn").on("click", function () { + const toast = $("#request-data-toast"); + bootstrap.Toast.getOrCreateInstance(toast).show(); + }); +}); diff --git a/app/assets/javascripts/submissions.coffee b/app/assets/javascripts/submissions.coffee index 3224b5669..cf4d9c990 100644 --- a/app/assets/javascripts/submissions.coffee +++ b/app/assets/javascripts/submissions.coffee @@ -1,5 +1,4 @@ $(document).on 'turbolinks:load', -> - clipboard = new Clipboard('.clipboard-btn') $(document).on 'click', '#removeUserManuscript', -> $('#userManuscriptMetadata').hide() @@ -9,20 +8,9 @@ $(document).on 'turbolinks:load', -> $('#submission_detach_user_manuscript').val('true') return - $(document).on 'click', '.clipboard-button', -> - $('.token-clipboard-popup').removeClass('show') - id = $(this).data('id') - $('.token-clipboard-popup[data-id="'+id+'"]').addClass('show') - restoreClipboardButton = -> - $('.token-clipboard-popup[data-id="'+id+'"]').removeClass('show') - return - setTimeout(restoreClipboardButton, 1500) - return - return # clean up for turbolinks $(document).on 'turbolinks:before-cache', -> $(document).off 'click', '#removeUserManuscript' - $(document).off 'click', '.clipboard-button' return \ No newline at end of file diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 7a69c64f8..eaa73aa06 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -194,10 +194,6 @@ $modal-xxl: 1600px !default; overflow-y: scroll; } -#profileChange { - display: none; -} - .revertCommontator { all: unset; } diff --git a/app/assets/stylesheets/lectures.scss b/app/assets/stylesheets/lectures.scss index be454a4d7..1476a1c4b 100644 --- a/app/assets/stylesheets/lectures.scss +++ b/app/assets/stylesheets/lectures.scss @@ -123,6 +123,16 @@ h3.lecture-pane-header { font-size: 1.3em; } +h4.lecture-pane-subheader { + color: #838383; + font-size: 1.1em; +} + +.voucher-card { + border: gray 1px solid; + border-radius: 0.4em; +} + #announcements-list { max-height: 17em; overflow-x: hidden; diff --git a/app/assets/stylesheets/profile.scss b/app/assets/stylesheets/profile.scss new file mode 100644 index 000000000..f1a4597d5 --- /dev/null +++ b/app/assets/stylesheets/profile.scss @@ -0,0 +1,53 @@ +@import "bootstrap/functions"; +@import "bootstrap/variables"; +@import "bootstrap/mixins"; + +.voucher-redemption-pattern { + background-image: image-url('voronoi_pattern.svg'); + background-position: center; + background-size: 110%; + background-color: #FFFFFF; + height: 3em; + + border-top-left-radius: calc(#{$card-inner-border-radius} - 2px); + border-top-right-radius: calc(#{$card-inner-border-radius} - 2px); +} + +.profile-card { + height: 100%; + border: 2px solid #223e62; + + background: linear-gradient(348deg, #f7faff 0%, #fcfdfd 60%); + box-shadow: rgba(0, 0, 0, 0.25) 0px 1px 3px; + + .card-header { + background-color: #223e62; + color: white; + + // account for our custom border + border-top-left-radius: calc(#{$card-inner-border-radius} - 2px); + border-top-right-radius: calc(#{$card-inner-border-radius} - 2px); + } +} + +@include media-breakpoint-up(sm) { + .voucher-card { + min-width: 30em; + } +} + +// needed because of https://github.com/bootstrap-ruby/bootstrap_form/issues/738 +// and https://github.com/bootstrap-ruby/bootstrap_form/pull/618 +.checkbox-list { + margin-bottom: 0.2em !important; +} + +// Ensure equal height columns +.row.display-flex { + display: flex; + flex-wrap: wrap; +} + +.row.display-flex>[class*='col-'] { + flex-grow: 1; +} \ No newline at end of file diff --git a/app/assets/stylesheets/submissions.scss b/app/assets/stylesheets/submissions.scss index 365213740..4b06d0fd1 100644 --- a/app/assets/stylesheets/submissions.scss +++ b/app/assets/stylesheets/submissions.scss @@ -18,7 +18,7 @@ } /* The actual popup */ -.clipboardpopup .clipboardpopuptext { +.clipboardpopuptext { visibility: hidden; width: 200px; background-color: #555; diff --git a/app/controllers/annotations_controller.rb b/app/controllers/annotations_controller.rb index 67d579757..6e6ea47b7 100644 --- a/app/controllers/annotations_controller.rb +++ b/app/controllers/annotations_controller.rb @@ -91,17 +91,8 @@ def destroy def update_annotations medium = Medium.find_by(id: params[:mediumId]) - # Get the right annotations - annotations = if medium.annotations_visible?(current_user) - Annotation.where(medium: medium, - visible_for_teacher: true).or( - Annotation.where(medium: medium, - user: current_user) - ) - else - Annotation.where(medium: medium, - user: current_user) - end + annotations = Annotation.where(medium: medium, visible_for_teacher: true) + .or(Annotation.where(medium: medium, user: current_user)) # If annotation is associated to a comment, # the field "comment" is empty -> get it from the commontator comment diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 0a5a60683..dfe2dbb0f 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -8,6 +8,7 @@ class ApplicationController < ActionController::Base before_action :authenticate_user! before_action :set_locale after_action :store_interaction, if: :user_signed_in? + before_action :set_current_user etag { current_user.try(:id) } @@ -111,6 +112,9 @@ def store_interaction # as of Rack 2.0.8, the session_id is wrapped in a class of its own # it is not a string anymore # see https://github.com/rack/rack/issues/1433 + + return if request.session_options[:id].nil? + InteractionSaver.perform_async(request.session_options[:id].public_id, request.original_fullpath, request.referer, @@ -132,4 +136,9 @@ def cookie_locale_param def available_locales I18n.available_locales.map(&:to_s) end + + # https://stackoverflow.com/a/69313330/ + def set_current_user + Current.user = current_user + end end diff --git a/app/controllers/concerns/.keep b/app/controllers/concerns/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/controllers/cypress/factories_controller.rb b/app/controllers/cypress/factories_controller.rb index 157e9e331..f2ced3932 100644 --- a/app/controllers/cypress/factories_controller.rb +++ b/app/controllers/cypress/factories_controller.rb @@ -5,28 +5,57 @@ module Cypress # It is inspired by this blog post by Tom Conroy: # https://tbconroy.com/2018/04/07/creating-data-with-factorybot-for-rails-cypress-tests/ class FactoriesController < CypressController - # Wrapper around FactoryBot.create to create a factory via a POST request. + # Creates an instance of the factory (via FactoryBot) and returns it as JSON. def create - unless params["0"].is_a?(String) - msg = "First argument must be a string indicating the factory name." - msg += " But we got: '#{params["0"]}'" - raise(ArgumentError, msg) - end + factory_name = validate_factory_name(params["0"]) + attributes, should_validate = params_to_attributes( + params.except(:controller, :action, :number) + ) + res = create_class_instance_via_factorybot(attributes, should_validate) + + # The factory name is included in the response such that it can be passed + # to call_instance_method later on in order to determine the class of the instance. + render json: res.as_json.merge({ factory_name: factory_name }), status: :created + end - attributes, should_validate = params_to_attributes(params.except(:controller, :action, - :number)) + # Calls the instance method on the instance created by FactoryBot.create(). + # Expects as arguments the factory name, the id of the instance, + # the method name and the method arguments to be passed to the instance method. + def call_instance_method + factory_name = validate_factory_name(params["factory_name"]).capitalize + id = params["instance_id"].to_i + method_name = params["method_name"] + method_args = params["method_args"] + method_args, _validate = params_to_attributes(method_args) if method_args.present? - res = if should_validate - FactoryBot.create(*attributes) # default case - else - FactoryBot.build(*attributes).tap { |instance| instance.save(validate: false) } + # Find the instance + begin + instance = factory_name.constantize.find(id) + rescue ActiveRecord::RecordNotFound + result = { error: "Instance where you'd like to call '#{method_name}' on was not found" } + return render json: result.to_json, status: :bad_request end - render json: res.to_json, status: :created + # Call the instance method & return the result + begin + result = instance.send(method_name, *method_args) + render json: result.to_json, status: :created + rescue NoMethodError => _e + result = { error: "Method '#{method_name}' not found on instance" } + render json: result.to_json, status: :bad_request + end end private + def validate_factory_name(factory_name) + return factory_name if factory_name.is_a?(String) + + msg = "First argument must be a string indicating the factory name." + msg += " But we got: '#{factory_name}'" + raise(ArgumentError, msg) + end + def params_to_attributes(params) should_validate = true @@ -35,7 +64,7 @@ def params_to_attributes(params) if value.key?("validate") should_validate = (value["validate"] != "false") else - value.transform_keys(&:to_sym) + transform_hash(value) end elsif value.is_a?(String) value.to_sym @@ -46,5 +75,34 @@ def params_to_attributes(params) return attributes, should_validate end + + # Converts the keys of the hash to symbols. Furthermore, if the hash + # contains nested hashes with keys that are all integers, it converts + # the nested hashes to arrays of strings. + # + # The latter is important for calls like the following in Cypress: + # FactoryBot.create("tutorial", + # { lecture_id: this.lecture.id, tutor_ids: [this.tutor1.id, this.tutor2.id] } + # ) + # Without this transformation, the create() method in this controller + # would receive [:tutorial, {"lecture_id"=>"1", "tutor_ids"=>{"0"=>"42", "1"=>"43"}}], + # whereas what we need is: [:tutorial, {"lecture_id"=>"1", "tutor_ids"=>["42", "43"]}]. + def transform_hash(value) + value.transform_keys(&:to_sym).transform_values do |v| + if v.is_a?(Hash) && v.keys.all? { |key| key.to_i.to_s } + v.values.map(&:to_s) + else + v + end + end + end + + def create_class_instance_via_factorybot(attributes, should_validate) + if should_validate + FactoryBot.create(*attributes) # default case + else + FactoryBot.build(*attributes).tap { |instance| instance.save(validate: false) } + end + end end end diff --git a/app/controllers/cypress/i18n_controller.rb b/app/controllers/cypress/i18n_controller.rb index cc3e5ec1a..cd68c4e91 100644 --- a/app/controllers/cypress/i18n_controller.rb +++ b/app/controllers/cypress/i18n_controller.rb @@ -10,16 +10,16 @@ def create substitutions = {} if params[:substitutions].present? - unless params[:substitutions].is_a?(Hash) - msg = "Argument `substitution` must be a hash indicating the substitutions." - msg += " But we got: '#{params[:substitutions]}'" + begin + substitutions = params[:substitutions].to_unsafe_hash.symbolize_keys + rescue NoMethodError + msg = "Argument `substitution` is '#{params[:substitutions]}'." + msg += " We cannot convert that to a hash." raise(ArgumentError, msg) end - substitutions = params[:substitutions].to_unsafe_hash.symbolize_keys end i18n_key = params[:i18n_key] - render json: I18n.t(i18n_key, **substitutions), status: :created end end diff --git a/app/controllers/cypress/timecop_controller.rb b/app/controllers/cypress/timecop_controller.rb new file mode 100644 index 000000000..f8378568d --- /dev/null +++ b/app/controllers/cypress/timecop_controller.rb @@ -0,0 +1,25 @@ +module Cypress + # Allows to travel to a date in the backend via Cypress tests. + + class TimecopController < CypressController + # Travels to a specific date and time. + # + # Time is passed as local time. If you want to pass a UTC time, set the + # parameter `use_utc` to true. + def travel + new_time = if params[:use_utc] == "true" + Time.utc(params[:year], params[:month], params[:day], + params[:hours], params[:minutes], params[:seconds]) + else + Time.zone.local(params[:year], params[:month], params[:day], + params[:hours], params[:minutes], params[:seconds]) + end + + render json: Timecop.travel(new_time), status: :created + end + + def reset + render json: Timecop.return, status: :created + end + end +end diff --git a/app/controllers/cypress/user_creator_controller.rb b/app/controllers/cypress/user_creator_controller.rb index f82b4dd18..29855eb03 100644 --- a/app/controllers/cypress/user_creator_controller.rb +++ b/app/controllers/cypress/user_creator_controller.rb @@ -1,6 +1,8 @@ module Cypress # Creates a user for use in Cypress tests. class UserCreatorController < CypressController + CYPRESS_PASSWORD = "cypress123".freeze + def create unless params[:role].is_a?(String) msg = "First argument must be a string indicating the user role." @@ -10,13 +12,18 @@ def create role = params[:role] is_admin = (role == "admin") + random_hash = SecureRandom.hex(6) - user = User.create(name: "#{role} Cypress", email: "#{role}@mampf.cypress", - password: "cypress123", consents: true, admin: is_admin, + user = User.create(name: "#{role} Cypress #{random_hash}", + email: "#{role}-#{random_hash}@mampf.cypress", + # Note that some Cypress tests rely on the username + # beginning with "cy" (!) + name_in_tutorials: "cy-#{role}-#{random_hash}", + password: CYPRESS_PASSWORD, consents: true, admin: is_admin, locale: I18n.default_locale) user.confirm - render json: user.to_json, status: :created + render json: user.as_json.merge({ password: CYPRESS_PASSWORD }), status: :created end end end diff --git a/app/controllers/lecture_notifier.rb b/app/controllers/lecture_notifier.rb new file mode 100644 index 000000000..a35103641 --- /dev/null +++ b/app/controllers/lecture_notifier.rb @@ -0,0 +1,45 @@ +module LectureNotifier + extend self + + def notify_new_editor_by_mail(editor, lecture) + LectureNotificationMailer.with(recipient: editor, + locale: editor.locale, + lecture: lecture) + .new_editor_email.deliver_later + end + + def notify_about_teacher_change_by_mail(lecture, previous_teacher) + notify_new_teacher_by_mail(lecture) + notify_previous_teacher_by_mail(previous_teacher, lecture) + end + + def notify_cospeakers_by_mail(speaker, talks) + talks.each do |talk| + talk.speakers.each do |cospeaker| + next if cospeaker == speaker + + LectureNotificationMailer.with(recipient: cospeaker, + locale: cospeaker.locale, + talk: talk, + speaker: speaker) + .new_speaker_email.deliver_later + end + end + end + + private + + def notify_new_teacher_by_mail(lecture) + LectureNotificationMailer.with(recipient: lecture.teacher, + locale: lecture.teacher.locale, + lecture: lecture) + .new_teacher_email.deliver_later + end + + def notify_previous_teacher_by_mail(previous_teacher, lecture) + LectureNotificationMailer.with(recipient: previous_teacher, + locale: previous_teacher.locale, + lecture: lecture) + .previous_teacher_email.deliver_later + end +end diff --git a/app/controllers/lectures_controller.rb b/app/controllers/lectures_controller.rb index 12fb42bdd..d0b3b1a48 100644 --- a/app/controllers/lectures_controller.rb +++ b/app/controllers/lectures_controller.rb @@ -66,15 +66,16 @@ def edit def create @lecture = Lecture.new(lecture_params) + @lecture.teacher = current_user unless current_user.admin? authorize! :create, @lecture @lecture.save if @lecture.valid? @lecture.update(sort: "special") if @lecture.course.term_independent # set organizational_concept to default set_organizational_defaults - # set lenguage to default language + # set language to default language set_language - # depending on where the create action was trriggered from, return + # depending on where the create action was triggered from, return # to admin index view or edit course view unless params[:lecture][:from] == "course" redirect_to administration_path, @@ -105,10 +106,7 @@ def update recipients = User.where(id: new_ids) recipients.each do |r| - NotificationMailer.with(recipient: r, - locale: r.locale, - lecture: @lecture) - .new_editor_email.deliver_later + LectureNotifier.notify_new_editor_by_mail(r, @lecture) end end @@ -336,9 +334,10 @@ def lecture_params :submission_max_team_size, :submission_grace_period, :annotations_status] if action_name == "update" && current_user.can_update_personell?(@lecture) - allowed_params.push(:teacher_id, { editor_ids: [] }) + allowed_params.push({ editor_ids: [] }) end - allowed_params.push(:course_id, :teacher_id, { editor_ids: [] }) if action_name == "create" + allowed_params.push(:course_id, { editor_ids: [] }) if action_name == "create" + allowed_params.push(:teacher_id) if current_user.admin? params.require(:lecture).permit(allowed_params) end diff --git a/app/controllers/profile_controller.rb b/app/controllers/profile_controller.rb index da24afab5..9a5722fb4 100644 --- a/app/controllers/profile_controller.rb +++ b/app/controllers/profile_controller.rb @@ -135,7 +135,6 @@ def show_accordion def request_data MathiMailer.data_request_email(current_user).deliver_later MathiMailer.data_provide_email(current_user).deliver_later - redirect_to edit_profile_path, notice: t("profile.data_request_sent") end private diff --git a/app/controllers/vouchers_controller.rb b/app/controllers/vouchers_controller.rb new file mode 100644 index 000000000..e73dc38cb --- /dev/null +++ b/app/controllers/vouchers_controller.rb @@ -0,0 +1,129 @@ +class VouchersController < ApplicationController + load_and_authorize_resource + before_action :find_voucher, only: :invalidate + + def current_ability + @current_ability ||= VoucherAbility.new(current_user) + end + + def create + set_related_data + respond_to do |format| + if @voucher.save + handle_successful_save(format) + else + handle_failed_save(format) + end + end + end + + def invalidate + set_related_data + @voucher.update(invalidated_at: Time.zone.now) + respond_to do |format| + format.html { redirect_to edit_lecture_path(@lecture, anchor: "people") } + format.js + end + end + + def verify + @voucher = Voucher.find_voucher_by_hash(params[:secure_hash]) + respond_to do |format| + if @voucher + format.js + format.html { head :no_content } + else + error_message = I18n.t("controllers.voucher_invalid") + format.js { render "error", locals: { error_message: error_message } } + format.html { redirect_to edit_profile_path, alert: error_message } + end + end + end + + def redeem + voucher = Voucher.find_voucher_by_hash(params[:secure_hash]) + if voucher + voucher.redeem(params.permit(tutorial_ids: [], talk_ids: [])) + redirect_to edit_profile_path, notice: success_message(voucher) + else + handle_invalid_voucher + end + end + + def cancel + respond_to do |format| + format.html { redirect_to edit_profile_path } + format.js + end + end + + private + + def voucher_params + params.permit(:lecture_id, :role) + end + + def find_voucher + @voucher = Voucher.find_by(id: params[:id]) + return if @voucher + + handle_voucher_not_found + end + + def set_related_data + @lecture = @voucher.lecture + @role = @voucher.role + I18n.locale = @lecture.locale + end + + def success_message(voucher) + if voucher.tutor? + I18n.t("controllers.become_tutor_success") + elsif voucher.editor? + I18n.t("controllers.become_editor_success") + elsif voucher.teacher? + I18n.t("controllers.become_teacher_success") + elsif voucher.speaker? + I18n.t("controllers.become_speaker_success") + end + end + + def handle_successful_save(format) + format.html { redirect_to edit_lecture_path(@lecture, anchor: "people") } + format.js + end + + def handle_failed_save(format) + error_message = @voucher.errors.full_messages.join(", ") + format.html do + redirect_to edit_lecture_path(@lecture, anchor: "people"), + alert: error_message + end + format.js do + render "error", locals: { error_message: error_message } + end + end + + def handle_voucher_not_found + I18n.locale = current_user.locale + error_message = I18n.t("controllers.no_voucher") + respond_to do |format| + format.html do + redirect_back(alert: error_message, + fallback_location: root_path) + end + format.js do + render "error", + locals: { error_message: error_message } + end + end + end + + def handle_invalid_voucher + error_message = I18n.t("controllers.voucher_invalid") + respond_to do |format| + format.js { render "error", locals: { error_message: error_message } } + format.html { redirect_to edit_profile_path, alert: error_message } + end + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index f267191e3..495f7a96e 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -327,4 +327,10 @@ def get_class_for_any_path(paths) def get_class_for_any_path_startswith(paths) paths.any? { |path| request.path.starts_with?(path) } ? ACTIVE_CSS_CLASS : "" end + + def truncate_result(result, length = 40) + result.first(length).tap do |truncated| + return truncated.length < length ? truncated : "#{truncated}..." + end + end end diff --git a/app/helpers/lectures_helper.rb b/app/helpers/lectures_helper.rb index b7d7f739e..40bdd69b2 100644 --- a/app/helpers/lectures_helper.rb +++ b/app/helpers/lectures_helper.rb @@ -129,4 +129,84 @@ def lecture_view_icon(lecture) tag.i(class: "fas fa-eye") end end + + def editors_preselection(lecture) + options_for_select(lecture.eligible_as_editors.map do |editor| + [editor.info, editor.id] + end, lecture.editor_ids) + end + + def teacher_select(form, is_new_lecture, lecture = nil) + if current_user.admin? + label = form.label(:teacher_id, t("basics.teacher"), class: "form-label") + help_desk = helpdesk(t("admin.lecture.info.teacher"), false) + + preselection = if is_new_lecture + options_for_select([[current_user.info, current_user.id]], current_user.id) + else + options_for_select([[lecture.teacher.info, lecture.teacher.id]], lecture.teacher.id) + end + + # TODO: Rubocop bug when trying to break the last object on a new line + select = form.select(:teacher_id, preselection, {}, { class: "selectize", + multiple: true, + data: { + ajax: true, + filled: false, + model: "user", + placeholder: t("basics.enter_two_letters"), # rubocop:disable Layout/LineLength + no_results: t("basics.no_results"), + modal: true, + cy: "teacher-admin-select" + } }) + + error_div = content_tag(:div, "", class: "invalid-feedback", id: "lecture-teacher-error") + + return label + help_desk + select + error_div + end + + # Non-admin cases + if is_new_lecture + p1 = content_tag(:p) do + concat(t("basics.teacher")) + concat(helpdesk(t("admin.lecture.info.teacher_fixed_new_lecture"), false)) + end + p2 = content_tag(:p, current_user.info) + + else + p1 = content_tag(:p) do + concat(t("basics.teacher")) + concat(helpdesk(t("admin.lecture.info.teacher_fixed"), false)) + end + p2 = content_tag(:p, lecture.teacher.info, "data-cy": "teacher-info") + end + + p1 + p2 + end + + def editors_select(form, lecture) + if current_user.admin? + preselection = options_for_select(lecture.select_editors, lecture.editors.map(&:id)) + form.select(:editor_ids, preselection, {}, { + class: "selectize", + multiple: true, + data: { + ajax: true, + filled: false, + model: "user", + placeholder: t("basics.enter_two_letters"), + no_results: t("basics.no_results"), + modal: true + } + }) + else + form.select(:editor_ids, editors_preselection(lecture), {}, + class: "selectize", + multiple: true, + data: { + cy: "lecture-editors-select", + no_results: t("basics.no_results_editor") + }) + end + end end diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb index be7160ec2..6efe5a44e 100644 --- a/app/helpers/notifications_helper.rb +++ b/app/helpers/notifications_helper.rb @@ -7,6 +7,7 @@ def notification_menu_item_header(notification) return medium_notification_item_header(notifiable) if notification.medium? return course_notification_item_header(notifiable) if notification.course? return lecture_notification_item_header(notifiable) if notification.lecture? + return redemption_notification_item_header(notifiable) if notification.redemption? announcement_notification_item_header(notifiable) end @@ -17,6 +18,7 @@ def notification_menu_item_details(notification) return medium_notification_item_details(notifiable) if notification.medium? return course_notification_item_details(notifiable) if notification.course? return lecture_notification_item_details(notifiable) if notification.lecture? + return redemption_notification_item_details(notifiable) if notification.redemption? "" end @@ -26,6 +28,7 @@ def notification_color(notification) return "bg-post-it-blue" if notification.generic_announcement? return "bg-post-it-red" if notification.announcement? return "bg-post-it-orange" if notification.course? || notification.lecture? + return "bg-post-it-green" if notification.redemption? "bg-post-it-yellow" end @@ -39,6 +42,8 @@ def notification_header(notification) t("notifications.course_selection") elsif notification.lecture_announcement? announcement_notification_card_header(notifiable) + elsif notification.redemption? + redemption_notification_card_header(notifiable) else link_to(t("mampf_news.title"), news_path, class: "text-dark") end @@ -53,6 +58,8 @@ def notification_text(notification) course_notification_card_text(notifiable) elsif notification.lecture? lecture_notification_card_text(notifiable) + elsif notification.redemption? + t("notifications.redemption") else t("notifications.new_announcement") end @@ -69,6 +76,8 @@ def notification_link(notification) course_notification_card_link elsif notification.lecture? lecture_notification_card_link + elsif notification.redemption? + redemption_notification_details(notifiable) else notifiable.details end diff --git a/app/helpers/redemptions_helper.rb b/app/helpers/redemptions_helper.rb new file mode 100644 index 000000000..50cbad850 --- /dev/null +++ b/app/helpers/redemptions_helper.rb @@ -0,0 +1,102 @@ +# Redemptions Helper +module RedemptionsHelper + def redemption_notification_card_header(redemption) + link_to(redemption.voucher.lecture.title_for_viewers, + edit_lecture_path(redemption.voucher.lecture, + anchor: ("people" unless redemption.voucher.speaker?)), + class: "text-dark") + end + + def redemption_notification_item_header(redemption) + t("notifications.redemption_in_lecture", + lecture: redemption.voucher.lecture.title_for_viewers) + end + + def redemption_notification_details(redemption) + if redemption.voucher.tutor? + tutor_notification_details(redemption) + elsif redemption.voucher.editor? + editor_notification_details(redemption) + elsif redemption.voucher.teacher? + teacher_notification_details(redemption) + else + speaker_notification_details(redemption) + end + end + + def redemption_notification_item_details(redemption) + result = if redemption.voucher.tutor? + tutor_notification_item_details(redemption) + elsif redemption.voucher.editor? + editor_notification_item_details(redemption) + elsif redemption.voucher.teacher? + teacher_notification_item_details(redemption) + else + speaker_notification_item_details(redemption) + end + + truncate_result(result) + end + + private + + def tutor_notification_item_details(redemption) + tutorials = redemption.claimed_tutorials + tutorial_details = tutorials.map(&:title).join(", ") + + base_message = "#{t("basics.tutor")} #{redemption.user.tutorial_name}" + tutorials.any? ? "#{base_message}: #{tutorial_details}" : base_message + end + + def editor_notification_item_details(redemption) + "#{t("basics.editor")} #{redemption.user.tutorial_name}" + end + + def teacher_notification_item_details(redemption) + "#{t("basics.teacher")} #{redemption.user.tutorial_name}" + end + + def speaker_notification_item_details(redemption) + talks = redemption.claimed_talks + talk_details = talks.map(&:to_label).join(", ") + + base_message = "#{t("basics.speaker")} #{redemption.user.tutorial_name}" + talks.any? ? "#{base_message}: #{talk_details}" : base_message + end + + def tutor_notification_details(redemption) + user_info = I18n.t("notifications.became_tutor", user: redemption.user.info) + tutorials = redemption.claimed_tutorials + + tutorial_details = if tutorials.present? + I18n.t("notifications.tutorial_details", + tutorials: tutorials.map(&:title).join(", ")) + else + I18n.t("notifications.no_tutorials_taken") + end + + user_info + tutorial_details + end + + def editor_notification_details(redemption) + I18n.t("notifications.became_editor", user: redemption.user.info) + end + + def teacher_notification_details(redemption) + I18n.t("notifications.became_teacher", user: redemption.user.info) + end + + def speaker_notification_details(redemption) + user_info = I18n.t("notifications.became_speaker", user: redemption.user.info) + talks = redemption.claimed_talks + + talk_details = if talks.present? + I18n.t("notifications.talk_details", + talks: talks.map(&:to_label).join(", ")) + else + I18n.t("notifications.no_talks_taken") + end + + user_info + talk_details + end +end diff --git a/app/helpers/submissions_helper.rb b/app/helpers/submissions_helper.rb index 480418fd3..81b232ed1 100644 --- a/app/helpers/submissions_helper.rb +++ b/app/helpers/submissions_helper.rb @@ -39,7 +39,7 @@ def submission_color(submission, assignment) else return "bg-submission-darker-green" if submission&.correction - if submission&.manuscript && submission&.too_late? + if submission&.manuscript && submission.too_late? return "bg-submission-orange" if submission.accepted.nil? return "bg-submission-green" if submission.accepted @@ -58,7 +58,7 @@ def submission_status_icon(submission, assignment) else return "far fa-smile" if submission&.correction - if submission&.manuscript && submission&.too_late? + if submission&.manuscript && submission.too_late? return "fas fa-hourglass-start" if submission.accepted return "fas fa-exclamation-triangle" @@ -76,7 +76,7 @@ def submission_status_text(submission, assignment) else return t("submission.with_correction") if submission&.correction - if submission&.manuscript && submission&.too_late? + if submission&.manuscript && submission.too_late? return t("submission.too_late") if submission.accepted.nil? return t("submission.too_late_accepted") if submission.accepted diff --git a/app/helpers/talks_helper.rb b/app/helpers/talks_helper.rb index 4546dba81..d9337a793 100644 --- a/app/helpers/talks_helper.rb +++ b/app/helpers/talks_helper.rb @@ -42,4 +42,44 @@ def date_list(talk) def cospeaker_list(talk, user) (talk.speakers.to_a - [user]).map(&:tutorial_name).join(", ") end + + def speakers_preselection(talk) + options_for_select(talk.lecture.eligible_as_speakers.map do |s| + [s.tutorial_info, s.id] + end, talk.speaker_ids) + end + + def speaker_select(form, talk, with_preselection) + label = form.label(:speaker_ids, t("admin.talk.speakers"), class: "form-label") + help_desk = helpdesk(t("admin.talk.info.speakers"), false) + + select = if current_user.admin? + preselection = with_preselection ? speakers_preselection(talk) : [[]] + form.select(:speaker_ids, preselection, {}, { + class: "selectize", + multiple: true, + data: { + ajax: true, + filled: false, + model: "user", + placeholder: t("basics.enter_two_letters"), + no_results: t("basics.no_results"), + modal: true, + cy: "speaker-select" + } + }) + else + seminar_edit_people_link = "#{edit_lecture_path(talk.lecture)}#people" + + form.select(:speaker_ids, speakers_preselection(talk), {}, + class: "selectize", + multiple: true, + data: { + cy: "speaker-select", + no_results: t("basics.no_results_speaker", link: seminar_edit_people_link) + }) + end + + label + help_desk + select + end end diff --git a/app/helpers/tutorials_helper.rb b/app/helpers/tutorials_helper.rb index 7cb929bf7..533099896 100644 --- a/app/helpers/tutorials_helper.rb +++ b/app/helpers/tutorials_helper.rb @@ -6,11 +6,10 @@ def cancel_editing_tutorial_path(tutorial) cancel_new_tutorial_path(params: { lecture: tutorial.lecture }) end - def tutorial_preselection(tutorial) - return [[]] unless tutorial.persisted? && tutorial.tutors.any? - - options_for_select(tutorial.tutors.map { |t| [t.tutorial_info, t.id] }, - tutorial.tutor_ids) + def tutors_preselection(tutorial) + options_for_select(tutorial.lecture.eligible_as_tutors.map do |t| + [t.tutorial_info, t.id] + end, tutorial.tutor_ids) end def tutorials_selection(lecture) diff --git a/app/helpers/vouchers_helper.rb b/app/helpers/vouchers_helper.rb new file mode 100644 index 000000000..9bde9da15 --- /dev/null +++ b/app/helpers/vouchers_helper.rb @@ -0,0 +1,50 @@ +module VouchersHelper + def tutorial_options(user, voucher) + voucher.lecture.tutorials_without_tutor(user).map { |t| [t.title, t.id] } + end + + def given_tutorial_ids(user, voucher) + user.given_tutorials.where(lecture: voucher.lecture).pluck(:id) + end + + def tutorials_with_tutor_titles(user, voucher) + voucher.lecture.tutorials_with_tutor(user).map(&:title).join(", ") + end + + def talks_with_titles(user, voucher) + voucher.lecture.talks_with_speaker(user).map(&:to_label).join(", ") + end + + def talk_options(user, voucher) + voucher.lecture.talks_without_speaker(user) + .map { |t| [t.to_label_with_speakers, t.id] } + end + + def redeem_voucher_button(voucher) + link_to(t("profile.redeem_voucher"), + redeem_voucher_path(params: { secure_hash: voucher.secure_hash }), + class: "btn btn-primary", + data: { cy: "redeem-voucher-btn" }, + method: :post, remote: true) + end + + def cancel_voucher_button + link_to(t("buttons.cancel"), cancel_voucher_path, + class: "btn btn-secondary ms-2", data: { cy: "cancel-voucher-btn" }, + remote: true) + end + + def claim_select_field(form, user, voucher) + field_name, options, prompt = if voucher.tutor? + [:tutorial_ids, tutorial_options(user, voucher), t("profile.select_tutorials")] + elsif voucher.speaker? + [:talk_ids, talk_options(user, voucher), t("profile.select_talks")] + end + + form.select(field_name, + options_for_select(options), + { prompt: prompt }, + { multiple: true, class: "selectize me-2", style: "width: 20rem", + data: { cy: "claim-select" } }) + end +end diff --git a/app/mailers/lecture_notification_mailer.rb b/app/mailers/lecture_notification_mailer.rb new file mode 100644 index 000000000..62263765a --- /dev/null +++ b/app/mailers/lecture_notification_mailer.rb @@ -0,0 +1,51 @@ +class LectureNotificationMailer < ApplicationMailer + before_action { I18n.locale = params[:locale] } + before_action { @sender = NotificationMailer.sender(params[:locale]) } + + def new_editor_email + @lecture = params[:lecture] + @recipient = params[:recipient] + @username = @recipient.tutorial_name + + mail(from: @sender, + to: @recipient.email, + subject: t("mailer.new_editor_subject", + title: @lecture.title_for_viewers)) + end + + def new_teacher_email + @lecture = params[:lecture] + @recipient = params[:recipient] + @username = @recipient.tutorial_name + + mail(from: @sender, + to: @recipient.email, + subject: t("mailer.new_teacher_subject", + title: @lecture.title_for_viewers)) + end + + def previous_teacher_email + @lecture = params[:lecture] + @recipient = params[:recipient] + @username = @recipient.tutorial_name + + mail(from: @sender, + to: @recipient.email, + subject: t("mailer.previous_teacher_subject", + title: @lecture.title_for_viewers, + new_teacher: @lecture.teacher.tutorial_name)) + end + + def new_speaker_email + @talk = params[:talk] + @recipient = params[:recipient] + @speaker = params[:speaker].info + @username = @recipient.tutorial_name + + mail(from: @sender, + to: @recipient.email, + subject: t("mailer.new_speaker_subject", + seminar: @talk.lecture.title, + title: @talk.to_label)) + end +end diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index ae6a281c1..8445cbe7d 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -2,7 +2,6 @@ class NotificationMailer < ApplicationMailer before_action :set_sender_and_locale before_action :set_recipients, only: [:medium_email, :announcement_email, :new_lecture_email, - :new_editor_email, :submission_deletion_email, :submission_deletion_lecture_email, :submission_destruction_email, @@ -51,17 +50,6 @@ def new_lecture_email title: @lecture.title_for_viewers)) end - def new_editor_email - @lecture = params[:lecture] - @recipient = params[:recipient] - @username = @recipient.tutorial_name - - mail(from: @sender, - to: @recipient.email, - subject: t("mailer.new_editor_subject", - title: @lecture.title_for_viewers)) - end - def submission_invitation_email @recipient = params[:recipient] @assignment = params[:assignment] @@ -173,8 +161,16 @@ def submission_destruction_lecture_email lecture: @lecture.title)) end + def self.sender(locale) + I18n.t("mailer.notification", locale: locale) \ + +" <#{DefaultSetting::PROJECT_NOTIFICATION_EMAIL}>" + end + private + # This method should be replaced by the one above (self.sender). + # It only stays here during the transition phase where this file is split + # into multiple files regarding concerns like vouchers, submissions, etc. def set_sender_and_locale @sender = "#{t("mailer.notification")} <#{DefaultSetting::PROJECT_NOTIFICATION_EMAIL}>" I18n.locale = params[:locale] diff --git a/app/models/announcement.rb b/app/models/announcement.rb index 4504b0ff7..c1f2f4c28 100644 --- a/app/models/announcement.rb +++ b/app/models/announcement.rb @@ -4,6 +4,8 @@ class Announcement < ApplicationRecord belongs_to :lecture, optional: true, touch: true belongs_to :announcer, class_name: "User" + has_many :notifications, as: :notifiable, dependent: :destroy + validates :details, presence: true paginates_per 10 diff --git a/app/models/concerns/.keep b/app/models/concerns/.keep deleted file mode 100644 index e69de29bb..000000000 diff --git a/app/models/course.rb b/app/models/course.rb index 5edf63bfa..574e1f4e6 100644 --- a/app/models/course.rb +++ b/app/models/course.rb @@ -4,6 +4,8 @@ class Course < ApplicationRecord has_many :lectures, dependent: :destroy + has_many :notifications, as: :notifiable, dependent: :destroy + # tags are notions that treated in the course # e.g.: vector space, linear map are tags for the course 'Linear Algebra 1' has_many :course_tag_joins, dependent: :destroy diff --git a/app/models/current.rb b/app/models/current.rb new file mode 100644 index 000000000..619f480c5 --- /dev/null +++ b/app/models/current.rb @@ -0,0 +1,4 @@ +# https://api.rubyonrails.org/classes/ActiveSupport/CurrentAttributes.html +class Current < ActiveSupport::CurrentAttributes + attribute :user +end diff --git a/app/models/lecture.rb b/app/models/lecture.rb index 7fd197471..c42267298 100644 --- a/app/models/lecture.rb +++ b/app/models/lecture.rb @@ -4,6 +4,8 @@ class Lecture < ApplicationRecord belongs_to :course + has_many :notifications, as: :notifiable, dependent: :destroy + # teacher is the user that gives the lecture belongs_to :teacher, class_name: "User" @@ -63,6 +65,10 @@ class Lecture < ApplicationRecord # a lecture has many assignments (e.g. exercises with deadlines) has_many :assignments + # a lecture has many vouchers that can be redeemed to promote + # users to tutors, editors or teachers + has_many :vouchers, dependent: :destroy + # a lecture has many structure_ids, referring to the ids of structures # in the erdbeere database serialize :structure_ids, type: Array, coder: YAML @@ -830,12 +836,9 @@ def speakers User.where(id: SpeakerTalkJoin.where(talk: talks).select(:speaker_id)) end - def older_than?(timespan) - return true unless term - - term.begin_date <= Term.active.begin_date - timespan - end - + # Determines if the lecture is stale (i.e. older than one year). + # The age of the lecture is determined by the begin date of the term + # in which it was given and the begin date of the current term. def stale? older_than?(1.year) end @@ -844,6 +847,89 @@ def valid_annotations_status? [0, 1].include?(annotations_status) end + def active_voucher_of_role(role) + vouchers.where(role: role).active&.first + end + + def update_tutor_status!(user, selected_tutorials) + tutorials.find_each do |t| + t.add_tutor(user) if selected_tutorials.include?(t) + end + # touch to invalidate the cache + touch + end + + def update_editor_status!(user) + return if editors.include?(user) + + editors << user + # touch to invalidate the cache + touch + end + + def update_teacher_status!(user) + return if teacher == user + + previous_teacher = teacher + update(teacher: user) + editors << previous_teacher + # touch to invalidate the cache + touch + end + + def update_speaker_status!(user, selected_talks) + talks.find_each do |t| + t.add_speaker(user) if selected_talks.include?(t) + end + # touch to invalidate the cache + touch + end + + def eligible_as_tutors + (tutors + Redemption.tutors_by_redemption_in(self) + editors + [teacher]).uniq + # the first one should (in the future) actually be contained in the sum of + # the other ones, but in the transition phase where some tutor statuses were + # still given by the old system, this will not be true + end + + def eligible_as_editors + (editors + Redemption.editors_by_redemption_in(self) + course.editors - [teacher]).uniq + # the first one should (in the future) actually be contained in the sum of + # the other ones, but in the transition phase where some editor statuses were + # still given by the old system, this will not be true + end + + def eligible_as_teachers + (User.teachers + editors + course.editors + [teacher]).uniq + end + + def eligible_as_speakers + (speakers + Redemption.speakers_by_redemption_in(self) + editors + [teacher]).uniq + # the first one should (in the future) actually be contained in the sum of + # the other ones, but in the transition phase where some editor statuses were + # still given by the old system, this will not be true + end + + def editors_and_teacher + ([teacher] + editors).uniq + end + + def tutorials_with_tutor(tutor) + tutorials.where(id: tutorial_ids_for_tutor(tutor)) + end + + def tutorials_without_tutor(tutor) + tutorials.where.not(id: tutorial_ids_for_tutor(tutor)) + end + + def talks_with_speaker(speaker) + talks.where(id: talk_ids_for_speaker(speaker)) + end + + def talks_without_speaker(speaker) + talks.where.not(id: talk_ids_for_speaker(speaker)) + end + private # used for after save callback @@ -947,4 +1033,19 @@ def only_one_lecture errors.add(:course, :already_present) end + + def older_than?(timespan) + return false unless Term.active + return true unless term + + term.begin_date <= Term.active.begin_date - timespan + end + + def tutorial_ids_for_tutor(tutor) + TutorTutorialJoin.where(tutor: tutor).select(:tutorial_id) + end + + def talk_ids_for_speaker(speaker) + SpeakerTalkJoin.where(speaker: speaker).select(:talk_id) + end end diff --git a/app/models/lesson.rb b/app/models/lesson.rb index e2dad90be..13084d0d5 100644 --- a/app/models/lesson.rb +++ b/app/models/lesson.rb @@ -286,7 +286,7 @@ def probable_start_destination end def tags_without_section - tags.includes(:sections).select { |t| (t.sections & sections).empty? } + tags.includes(:sections).select { |t| (t.sections & sections).empty? } # rubocop:disable Style/ArrayIntersect end private diff --git a/app/models/medium.rb b/app/models/medium.rb index ab667c7cb..bd59866d5 100644 --- a/app/models/medium.rb +++ b/app/models/medium.rb @@ -3,6 +3,8 @@ class Medium < ApplicationRecord include ApplicationHelper include ActiveModel::Dirty + has_many :notifications, as: :notifiable, dependent: :destroy + # a teachable is a course/lecture/lesson belongs_to :teachable, polymorphic: true, optional: true acts_as_list scope: [:teachable_id, :teachable_type], top_of_list: 0 diff --git a/app/models/notification.rb b/app/models/notification.rb index ee62b890c..fb0627215 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -9,19 +9,12 @@ class Notification < ApplicationRecord paginates_per 12 - # retrieve notifiable defined by notifiable_type and notifiable_id - # def notifiable - # return unless notifiable_type.in?(Notification.allowed_notifiable_types) && - # notifiable_id.present? - # notifiable_type.constantize.find_by_id(notifiable_id) - # end - # returns the lecture associated to a notification of type announcement, # and teachable for a notification of type medium, nil otherwise def teachable return if notifiable.blank? - return if notifiable_type.in?(["Lecture", "Course"]) - return notifiable.lecture if notifiable_type == "Announcement" + return if lecture_or_course? + return notifiable.lecture if announcement_or_redemption? # notifiable will be a medium, so return its teachable notifiable.teachable @@ -34,46 +27,42 @@ def teachable # all other cases: notifiable path def path(user) return if notifiable.blank? - return edit_profile_path if notifiable_type.in?(["Course", "Lecture"]) - - if notifiable_type == "Announcement" - return notifiable.lecture.path(user) if notifiable.lecture.present? - return news_path + if redemption? + edit_lecture_path(notifiable.voucher.lecture, anchor: "people") + elsif lecture_or_course? + edit_profile_path + elsif lecture_announcement? + notifiable.lecture.path(user) + elsif generic_announcement? + news_path + elsif quiz? + medium_path(notifiable) + else + polymorphic_url(notifiable, only_path: true) end - return medium_path(notifiable) if notifiable_type == "Medium" && notifiable.sort == "Quiz" - - polymorphic_url(notifiable, only_path: true) - end - - def self.allowed_notifiable_types - ["Medium", "Course", "Lecture", "Announcement"] end # the next methods are for the determination which kind of notification it is def medium? - return false if notifiable.blank? - - notifiable_type == "Medium" + notifiable.is_a?(Medium) end def course? - return false if notifiable.blank? - - notifiable.instance_of?(::Course) + notifiable.is_a?(Course) end def lecture? - return false if notifiable.blank? + notifiable.is_a?(Lecture) + end - notifiable.instance_of?(::Lecture) + def redemption? + notifiable.is_a?(Redemption) end def announcement? - return false if notifiable.blank? - - notifiable.instance_of?(::Announcement) + notifiable.is_a?(Announcement) end def generic_announcement? @@ -83,4 +72,18 @@ def generic_announcement? def lecture_announcement? announcement? && notifiable.lecture.present? end + + def quiz? + medium? && notifiable.sort == "Quiz" + end + + private + + def lecture_or_course? + notifiable_type.in?(["Lecture", "Course"]) + end + + def announcement_or_redemption? + notifiable_type.in?(["Announcement", "Redemption"]) + end end diff --git a/app/models/talk.rb b/app/models/talk.rb index 2afef84a1..650eb1f22 100644 --- a/app/models/talk.rb +++ b/app/models/talk.rb @@ -4,6 +4,8 @@ class Talk < ApplicationRecord has_many :speaker_talk_joins, dependent: :destroy has_many :speakers, through: :speaker_talk_joins + has_many :claims, as: :claimable, dependent: :destroy + validates :title, presence: true # being a teachable (course/lecture/lesson), a talk has associated media @@ -35,6 +37,12 @@ def to_label I18n.t("talk", number: position, title: title) end + def to_label_with_speakers + return to_label unless speakers.any? + + "#{to_label} (#{speakers.map(&:tutorial_name).join(", ")})" + end + def long_title title_for_viewers end @@ -100,6 +108,10 @@ def editors_with_inheritance (speakers + lecture.editors_with_inheritance).uniq end + def add_speaker(speaker) + speakers << speaker unless speaker.in?(speakers) + end + private def touch_lecture diff --git a/app/models/tutorial.rb b/app/models/tutorial.rb index 824f390cf..8fd72418a 100644 --- a/app/models/tutorial.rb +++ b/app/models/tutorial.rb @@ -9,6 +9,8 @@ class Tutorial < ApplicationRecord has_many :submissions, dependent: :destroy + has_many :claims, as: :claimable, dependent: :destroy + before_destroy :check_destructibility, prepend: true # rubocop:todo Rails/UniqueValidationWithoutIndex @@ -41,6 +43,10 @@ def teams_to_csv(assignment) end end + def add_tutor(tutor) + tutors << tutor unless tutors.include?(tutor) + end + private def check_destructibility diff --git a/app/models/user.rb b/app/models/user.rb index 4d3337b0a..7c9d9d8b8 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -81,6 +81,9 @@ class User < ApplicationRecord has_many :feedbacks, dependent: :destroy + # a user has redemptions of vouchers + has_many :redemptions, dependent: :destroy + include ScreenshotUploader[:image] # if a homepage is given it should at leat be a valid address @@ -130,10 +133,6 @@ class User < ApplicationRecord scope :inactive_for, ->(threshold) { where(current_sign_in_at: ...threshold.ago) } scope :confirmation_sent_before, ->(threshold) { where(confirmation_sent_at: ...threshold.ago) } - searchable do - text :tutorial_name - end - # returns the array of all teachers def self.teachers User.where(id: Lecture.distinct.select(:teacher_id)) diff --git a/app/models/voucher/claim.rb b/app/models/voucher/claim.rb new file mode 100644 index 000000000..e8fe5aa6d --- /dev/null +++ b/app/models/voucher/claim.rb @@ -0,0 +1,6 @@ +# A Claim stores a Claimable that is being taken over by the user when they +# redeem a voucher. Claimables include tutorials and talks. +class Claim < ApplicationRecord + belongs_to :redemption + belongs_to :claimable, polymorphic: true +end diff --git a/app/models/voucher/redeemer.rb b/app/models/voucher/redeemer.rb new file mode 100644 index 000000000..ad9ede97c --- /dev/null +++ b/app/models/voucher/redeemer.rb @@ -0,0 +1,78 @@ +# The Redeemer module is included in the Voucher model to encapsulate the +# redemption logic of a voucher. +# +# Note that this is not the same as "Claimable", which is used for roles +# that can be claimed via a voucher, e.g. becoming a tutor for a lecture etc. +module Redeemer + extend ActiveSupport::Concern + + included do + has_many :redemptions, dependent: :destroy + end + + def redeem(params) + redemption = create_redemption(params) + create_notifications!(redemption) + Current.user.subscribe_lecture!(lecture) + end + + private + + def create_redemption(params) + case role.to_sym + when :tutor + redeem_tutor_voucher(params[:tutorial_ids]) + when :editor + redeem_editor_voucher + when :teacher + redeem_teacher_voucher + when :speaker + redeem_speaker_voucher(params[:talk_ids]) + end + end + + def redeem_tutor_voucher(tutorial_ids) + selected_tutorials = lecture.tutorials.where(id: tutorial_ids) + lecture.update_tutor_status!(Current.user, selected_tutorials) + + Redemption.create(user: Current.user, voucher: self, + claimed_tutorials: selected_tutorials) + end + + def redeem_editor_voucher + lecture.update_editor_status!(Current.user) + LectureNotifier.notify_new_editor_by_mail(Current.user, lecture) + + Redemption.create(user: Current.user, voucher: self) + end + + def redeem_teacher_voucher + previous_teacher = lecture.teacher + lecture.update_teacher_status!(Current.user) + # no need to send out notifications if the teacher stays the same + # because then there is no demotion to editor + # (it is actually not possible to trigger this case via the GUI) + if previous_teacher != Current.user + LectureNotifier.notify_about_teacher_change_by_mail(lecture, + previous_teacher) + end + invalidate! + + Redemption.create(user: Current.user, voucher: self) + end + + def redeem_speaker_voucher(talk_ids) + selected_talks = lecture.talks.where(id: talk_ids) + lecture.update_speaker_status!(Current.user, selected_talks) + LectureNotifier.notify_cospeakers_by_mail(Current.user, selected_talks) + + Redemption.create(user: Current.user, voucher: self, + claimed_talks: selected_talks) + end + + def create_notifications!(redemption) + lecture.editors_and_teacher.each do |editor| + Notification.create(notifiable: redemption, recipient: editor) + end + end +end diff --git a/app/models/voucher/redemption.rb b/app/models/voucher/redemption.rb new file mode 100644 index 000000000..2e6e68176 --- /dev/null +++ b/app/models/voucher/redemption.rb @@ -0,0 +1,44 @@ +# Redemptions store the event of a user redeeming a voucher. +# +# During one redemption of a voucher, a user might claim multiple objects, e.g. +# two tutorial slots. The respective claims are stored in the Claims model. +# +# Also provides class methods to find out about users who have redeemed +# specific vouchers, e.g. tutors by redemption in a given lecture. +class Redemption < ApplicationRecord + belongs_to :voucher + belongs_to :user + has_many :claims, dependent: :destroy + has_many :claimed_tutorials, through: :claims, source: :claimable, + source_type: Tutorial.name + has_many :claimed_talks, through: :claims, source: :claimable, + source_type: Talk.name + + has_many :notifications, as: :notifiable, dependent: :destroy + + class << self + def tutors_by_redemption_in(lecture) + users_that_redeemed_vouchers(lecture.vouchers.for_tutors) + end + + def editors_by_redemption_in(lecture) + users_that_redeemed_vouchers(lecture.vouchers.for_editors) + end + + def speakers_by_redemption_in(lecture) + users_that_redeemed_vouchers(lecture.vouchers.for_speakers) + end + + private + + # Returns the users who have redeemed the given vouchers. + # + # These users could be called "Redeemers", but note that this should not + # be confused with the Redeemer module that is responsible for the redemption + # process of a voucher. + def users_that_redeemed_vouchers(relevant_vouchers) + user_ids = Redemption.where(voucher: relevant_vouchers).pluck(:user_id) + User.where(id: user_ids.uniq) + end + end +end diff --git a/app/models/voucher/voucher.rb b/app/models/voucher/voucher.rb new file mode 100644 index 000000000..ccd1ac2da --- /dev/null +++ b/app/models/voucher/voucher.rb @@ -0,0 +1,94 @@ +# A voucher is a unique (secure) hash that can be used by users to redeem a role, +# such as tutor, teacher etc. That is, the voucher grants the user elevated +# permissions. +# +# Vouchers are created by lecture editors, e.g. teachers. They will then send +# the voucher to the user by means of a different communication channel, +# e.g. email. Users can redeem the voucher by entering the code on their +# profile page. +# +# Before the introduction of vouchers, teachers could select from the whole pool +# of MaMpf users to assign them a role, e.g. to select tutors for their lecture. +# To better align this process with GDPR requirements, the concept of voucher +# was introduced. This way, teachers can only assign roles to users who have +# actively redeemed a voucher. +class Voucher < ApplicationRecord + include Redeemer + + SPEAKER_EXPIRATION_DAYS = 30 + TUTOR_EXPIRATION_DAYS = 14 + DEFAULT_EXPIRATION_DAYS = 3 + + ROLE_HASH = { tutor: 0, editor: 1, teacher: 2, speaker: 3 }.freeze + enum role: ROLE_HASH + validates :role, presence: true + + belongs_to :lecture, touch: true + + before_create :generate_secure_hash + before_create :add_expiration_datetime + before_create :ensure_no_other_active_voucher + before_create :ensure_speaker_vouchers_only_for_seminars + + scope :active, lambda { + where("expires_at > ? AND invalidated_at IS NULL", + Time.zone.now) + } + scope :for_tutors, -> { where(role: :tutor) } + scope :for_editors, -> { where(role: :editor) } + scope :for_speakers, -> { where(role: :speaker) } + + self.implicit_order_column = :created_at + + def self.roles_for_lecture(lecture) + return ROLE_HASH.keys if lecture.seminar? + + ROLE_HASH.keys - [:speaker] + end + + def self.find_voucher_by_hash(secure_hash) + # strip() to avoid issues with leading/trailing whitespaces when copy-pasting + Voucher.active.find_by(secure_hash: secure_hash.strip) + end + + def invalidate! + update(invalidated_at: Time.zone.now) + end + + private + + def generate_secure_hash + self.secure_hash = SecureRandom.hex(16) + end + + def add_expiration_datetime + self.expires_at = created_at + expiration_days.days + end + + def ensure_no_other_active_voucher + return unless lecture + return unless lecture.vouchers.where(role: role).active.any? + + errors.add(:role, + I18n.t("activerecord.errors.models.voucher.attributes.role." \ + "only_one_active")) + throw(:abort) + end + + def ensure_speaker_vouchers_only_for_seminars + return unless speaker? + return if lecture.seminar? + + errors.add(:role, + I18n.t("activerecord.errors.models.voucher.attributes.role." \ + "speaker_vouchers_only_for_seminars")) + throw(:abort) + end + + def expiration_days + return SPEAKER_EXPIRATION_DAYS if speaker? + return TUTOR_EXPIRATION_DAYS if tutor? + + DEFAULT_EXPIRATION_DAYS + end +end diff --git a/app/views/administration/index/_my_courses.html.erb b/app/views/administration/index/_my_courses.html.erb index 93d8112bb..eb74229c8 100644 --- a/app/views/administration/index/_my_courses.html.erb +++ b/app/views/administration/index/_my_courses.html.erb @@ -18,12 +18,14 @@
- + <% if current_user.admin? %> + + <% end %> <% if current_user.edited_courses.any? %> <%= render partial: 'administration/index/courses_card', locals: { courses: diff --git a/app/views/annotations/_annotation_area.html.erb b/app/views/annotations/_annotation_area.html.erb index 2e0327e89..3bea2edd0 100644 --- a/app/views/annotations/_annotation_area.html.erb +++ b/app/views/annotations/_annotation_area.html.erb @@ -1,4 +1,4 @@ -
+
diff --git a/app/views/commontator/comments/_form.html.erb b/app/views/commontator/comments/_form.html.erb index 58ba9c579..c039e61c3 100644 --- a/app/views/commontator/comments/_form.html.erb +++ b/app/views/commontator/comments/_form.html.erb @@ -38,7 +38,9 @@
<%= - form.text_area :body, rows: '7', class: 'form-control commentForm comment-field', id: new_record ? + form.text_area :body, rows: '7', + "data-cy": "comment-textarea", + class: 'form-control commentForm comment-field', id: new_record ? comment.parent.nil? ? "commontator-thread-#{@commontator_thread.id}-new-comment-body" : "commontator-comment-#{comment.parent.id}-reply" : "commontator-comment-#{comment.id}-edit-body" @@ -48,7 +50,8 @@
<%= form.submit t("commontator.comment.actions.#{new_record ? 'create' : 'update'}"), - class: 'btn btn-primary btn-sm' %> + class: 'btn btn-primary btn-sm', + "data-cy": "submit-new-comment" %> <%= form.submit( t('commontator.comment.actions.cancel'), name: 'cancel', diff --git a/app/views/commontator/comments/_list.html.erb b/app/views/commontator/comments/_list.html.erb index 5ae58a0f9..e0ceb0428 100644 --- a/app/views/commontator/comments/_list.html.erb +++ b/app/views/commontator/comments/_list.html.erb @@ -6,7 +6,8 @@ %> <% nested_comments.each do |comment, nested_children| %> -
+
<%= render partial: 'commontator/comments/show', formats: [ :html ], locals: { user: user, comment: comment, nested_children: nested_children diff --git a/app/views/commontator/threads/_reply.html.erb b/app/views/commontator/threads/_reply.html.erb index ee5c77446..3a272969a 100644 --- a/app/views/commontator/threads/_reply.html.erb +++ b/app/views/commontator/threads/_reply.html.erb @@ -14,7 +14,8 @@ <% end %> diff --git a/app/views/layouts/application_no_sidebar.html.erb b/app/views/layouts/application_no_sidebar.html.erb index e23124bb0..24d45f890 100644 --- a/app/views/layouts/application_no_sidebar.html.erb +++ b/app/views/layouts/application_no_sidebar.html.erb @@ -17,7 +17,7 @@
<% end %> <% if notice.present? %> -
-
- <%= f.label :teacher_id, - t('basics.teacher'), - class: "form-label" %> - <%= helpdesk(t('admin.lecture.info.teacher'), false) %> - <%= f.select :teacher_id, - options_for_select([[current_user.info, current_user.id]], - current_user.id), - {}, - { class: 'selectize', - data: { ajax: true, - model: 'user', - filled: false, - placeholder: t('basics.enter_two_letters'), - no_results: t('basics.no_results'), - current: current_user.id, - modal: modal } } %> +
+ <%= teacher_select(f, is_new_lecture=true) %>
diff --git a/app/views/lectures/edit/_comments.html.erb b/app/views/lectures/edit/_comments.html.erb index 60af9b03f..ea9e4ecea 100644 --- a/app/views/lectures/edit/_comments.html.erb +++ b/app/views/lectures/edit/_comments.html.erb @@ -36,7 +36,7 @@
-
+
<%= t('admin.lecture.enable_annotation_button') %> <%= helpdesk(t('admin.lecture.enable_annotation_button_helpdesk'), false) %> diff --git a/app/views/lectures/edit/_form.html.erb b/app/views/lectures/edit/_form.html.erb index 5060e5b82..5aee9c997 100644 --- a/app/views/lectures/edit/_form.html.erb +++ b/app/views/lectures/edit/_form.html.erb @@ -6,7 +6,7 @@ <%= render partial: 'lectures/edit/header', locals: { lecture: lecture } %>
- +
- +
@@ -103,6 +105,8 @@ locals: { lecture: lecture } %> <%= render partial: 'lectures/edit/tutorials', locals: { lecture: lecture } %> + <%= render partial: 'lectures/edit/vouchers', + locals: { lecture: lecture } %>
@@ -117,6 +121,7 @@
<%= render partial: 'lectures/edit/announcements', @@ -145,7 +150,7 @@ <%= t('basics.course_editors') %> <%= helpdesk(t('admin.lecture.info.course_editors'), false) %> - +
<% if lecture.course.editors.present? %>
    @@ -179,7 +184,7 @@ <%= render partial: 'announcements/modal' %> <%= render partial: 'lectures/publish/publish', locals: { lecture: lecture } %> - + <% unless lecture.stale? %> <%= render partial: 'lectures/edit/user_modal', locals: { lecture: lecture } %> diff --git a/app/views/lectures/edit/_people.html.erb b/app/views/lectures/edit/_people.html.erb index e1d047f46..63b432872 100644 --- a/app/views/lectures/edit/_people.html.erb +++ b/app/views/lectures/edit/_people.html.erb @@ -3,28 +3,8 @@

    <%= t('basics.people') %>

    -
    - <%= f.label :teacher_id, - t('basics.teacher'), - class: "form-label" %> - <%= helpdesk(t('admin.lecture.info.teacher'), false) %> -
    - <%= f.select :teacher_id, - options_for_select([[lecture.teacher.info, - lecture.teacher.id]], - lecture.teacher.id), - {}, - { class: 'selectize', - data: { ajax: true, - model: 'user', - filled: false, - placeholder: - t('basics.enter_two_letters'), - no_results: - t('basics.no_results') } } %> -
    -
    -
    +
    + <%= teacher_select(f, is_new_lecture=false, lecture) %>
    @@ -32,21 +12,11 @@ t('basics.lecture_editors'), class: "form-label" %> <%= helpdesk(t('admin.lecture.info.lecture_editors'), false) %> -
    - <%= f.select :editor_ids, - options_for_select([[t('none'), '']] + - lecture.select_editors, - lecture.editors.map(&:id)), - {}, - { multiple: true, - class: 'selectize', - data: { ajax: true, - model: 'user', - filled: false, - placeholder: t('basics.enter_two_letters'), - no_results: t('basics.no_results') } } %> +
    + <%= editors_select(f, lecture) %>
    +
    diff --git a/app/views/lectures/edit/_seminar_content.html.erb b/app/views/lectures/edit/_seminar_content.html.erb index b7b74ead1..64675bcfd 100644 --- a/app/views/lectures/edit/_seminar_content.html.erb +++ b/app/views/lectures/edit/_seminar_content.html.erb @@ -16,6 +16,7 @@ new_talk_path(lecture_id: lecture.id), remote: true, class: 'btn btn-sm btn-secondary new-in-lecture', + data: { cy: 'new-talk-btn' }, id: 'new_talk_button' %>
    @@ -29,7 +30,9 @@ <% end %>
    <% else %> - <%= t('admin.lecture.no_talks') %> + + <%= t('admin.lecture.no_talks') %> + <% end %>
diff --git a/app/views/lectures/edit/_tutorials.html.erb b/app/views/lectures/edit/_tutorials.html.erb index 1dded934e..37ae6b55c 100644 --- a/app/views/lectures/edit/_tutorials.html.erb +++ b/app/views/lectures/edit/_tutorials.html.erb @@ -10,6 +10,7 @@ new_tutorial_path(params: { lecture_id: lecture.id }), class: 'btn btn-sm btn-primary', id: 'newTutorialButton', + data: { cy: 'new-tutorial-btn' }, remote: true %>
@@ -26,6 +27,7 @@
<%= t('basics.tutors') %> + <%= helpdesk(t('tutorial.info.tutors'), true) %>
diff --git a/app/views/lectures/edit/_vouchers.html.erb b/app/views/lectures/edit/_vouchers.html.erb new file mode 100644 index 000000000..8c9b2582e --- /dev/null +++ b/app/views/lectures/edit/_vouchers.html.erb @@ -0,0 +1,20 @@ +
+

+ <%= t('basics.vouchers') %> +

+ +

+ <%= t('admin.voucher.general_explanation') %> +

+ +<% if current_user.can_update_personell?(lecture) %> +
+ <% Voucher.roles_for_lecture(lecture).each do |role| %> +
+
+ <%= render partial: 'vouchers/voucher' , locals: { lecture: lecture, role: role } %> +
+
+ <% end %> +
+<% end %> diff --git a/app/views/lectures/error.js.erb b/app/views/lectures/error.js.erb new file mode 100644 index 000000000..debc3873c --- /dev/null +++ b/app/views/lectures/error.js.erb @@ -0,0 +1 @@ +alert("<%= "#{j(error_message)}" %>"); diff --git a/app/views/lectures/subscribe_page.html.erb b/app/views/lectures/subscribe_page.html.erb index de230afe3..9b50c3155 100644 --- a/app/views/lectures/subscribe_page.html.erb +++ b/app/views/lectures/subscribe_page.html.erb @@ -48,7 +48,8 @@ value: "redirect" %>

<%= f.submit t("profile.subscribe_lecture"), - class: "btn btn-primary"%> + class: "btn btn-primary", + "data-cy": "subscribe-to-lecture" %> <% end %> <% else %> <%= link_to t("profile.subscribe_lecture"), @@ -58,7 +59,8 @@ parent: "redirect" } }), method: :patch, remote: true, - class: "btn btn-primary" %> + class: "btn btn-primary", + "data-cy": "subscribe-to-lecture" %> <% end %>

diff --git a/app/views/main/start.html.erb b/app/views/main/start.html.erb index 8d16e8ab9..ae4cbc1bd 100644 --- a/app/views/main/start.html.erb +++ b/app/views/main/start.html.erb @@ -63,7 +63,8 @@ data-bs-parent="#subscriptionsAccordion" data-link="#inactiveLecturesLink">
+ id="collapseInactiveLecturesContent" + data-cy="subscribed-inactive-lectures-collapse"> <% if @current_stuff.empty? %> <%= render partial: 'main/start/lecture', collection: @inactive_lectures, @@ -117,7 +118,8 @@ data-bs-toggle="collapse" data-bs-target="#collapseTalks" aria-expanded="false" - aria-controls="collapseTalks"> + aria-controls="collapseTalks" + data-cy="my-talks-collapse-btn">
+ data-link="#talkLink" + data-cy="my-talks-collapse">
<%= render partial: 'main/start/talks', locals: { talks: @talks } %> diff --git a/app/views/media/_basics.html.erb b/app/views/media/_basics.html.erb index 2e6cdd877..773b09975 100644 --- a/app/views/media/_basics.html.erb +++ b/app/views/media/_basics.html.erb @@ -183,11 +183,11 @@
<%= f.radio_button :annotations_status, - '0', + '-1', class: 'form-check-input' %> <%= f.label :annotations_status, t('admin.annotation.inherit_from_lecture'), - value: '0', + value: '-1', class: 'form-check-label' %>
@@ -201,11 +201,11 @@
<%= f.radio_button :annotations_status, - '-1', + '0', class: 'form-check-input' %> <%= f.label :annotations_status, t('basics.no_lc'), - value: '-1', + value: '0', class: 'form-check-label' %>
diff --git a/app/views/media/feedback.html.erb b/app/views/media/feedback.html.erb index eb92333bf..843c83c2c 100644 --- a/app/views/media/feedback.html.erb +++ b/app/views/media/feedback.html.erb @@ -23,7 +23,8 @@ - + -
+
+
@@ -26,7 +26,7 @@
-
+
<%= simple_format(notification_text(notification)) %> <%= simple_format(notification_link(notification)) %>
diff --git a/app/views/profile/_account.html.erb b/app/views/profile/_account.html.erb index 0847f3662..e32eab35e 100644 --- a/app/views/profile/_account.html.erb +++ b/app/views/profile/_account.html.erb @@ -1,8 +1,14 @@ <%= link_to t('profile.change_data'), edit_user_registration_path, - class: "btn btn-outline-secondary mb-2" %> + class: "btn btn-outline-secondary" %> + + + <%= link_to t('profile.delete_account'), delete_account_path, - class: "btn btn-outline-danger mb-2", + class: "btn btn-outline-danger", + "data-cy": "delete-account-btn", remote: true %> - diff --git a/app/views/profile/_data.html.erb b/app/views/profile/_data.html.erb deleted file mode 100644 index e898e74f0..000000000 --- a/app/views/profile/_data.html.erb +++ /dev/null @@ -1,9 +0,0 @@ -
- <%= t('profile.data_explanation') %> -
-
- <%= link_to t('profile.data_request'), - request_data_path, - class: 'btn btn-sm btn-primary', - data: { confirm: t('confirmation.generic') } %> -
\ No newline at end of file diff --git a/app/views/profile/_email_notifications.html.erb b/app/views/profile/_email_notifications.html.erb index 7eb9a35c5..81d2586a1 100644 --- a/app/views/profile/_email_notifications.html.erb +++ b/app/views/profile/_email_notifications.html.erb @@ -9,13 +9,13 @@ <%= f.form_group :email_notifications do %> <%= f.check_box :email_for_announcement, label: t('profile.email_for_announcement'), - custom: true %> + wrapper_class: 'checkbox-list' %> <%= f.check_box :email_for_medium, label: t('profile.email_for_medium'), - custom: true %> + wrapper_class: 'checkbox-list' %> <%= f.check_box :email_for_teachable, label: t('profile.email_for_teachable'), - custom: true %> + wrapper_class: 'checkbox-list' %> <% end %>
@@ -25,22 +25,22 @@ <%= f.form_group :submission_emails do %> <%= f.check_box :email_for_submission_upload, label: t('profile.email_for_submission_upload'), - custom: true %> + wrapper_class: 'checkbox-list' %> <%= f.check_box :email_for_submission_removal, label: t('profile.email_for_submission_removal'), - custom: true %> + wrapper_class: 'checkbox-list' %> <%= f.check_box :email_for_submission_join, label: t('profile.email_for_submission_join'), - custom: true %> + wrapper_class: 'checkbox-list' %> <%= f.check_box :email_for_submission_leave, label: t('profile.email_for_submission_leave'), - custom: true %> + wrapper_class: 'checkbox-list' %> <%= f.check_box :email_for_correction_upload, label: t('profile.email_for_correction_upload'), - custom: true %> + wrapper_class: 'checkbox-list' %> <%= f.check_box :email_for_submission_decision, label: t('profile.email_for_submission_decision'), - custom: true %> + wrapper_class: 'checkbox-list' %> <% end %>
@@ -49,8 +49,7 @@ <%= f.form_group :other_email_notifications do %> <%= f.check_box :email_for_news, - label: t('profile.email_for_news'), - custom: true %> + label: t('profile.email_for_news') %> <% end %>
\ No newline at end of file diff --git a/app/views/profile/_redeem_voucher.html.erb b/app/views/profile/_redeem_voucher.html.erb new file mode 100644 index 000000000..a9278a5f0 --- /dev/null +++ b/app/views/profile/_redeem_voucher.html.erb @@ -0,0 +1,9 @@ +<%= form_with url: redeem_voucher_path, + remote: true, + method: :post, + html: { class: "form-inline" } do |f| %> +
+ <%= f.text_field :voucher_hash, class: "form-control me-2 w-50" %> + <%= f.submit t('profile.redeem_voucher'), class: "btn btn-primary" %> +
+<% end %> diff --git a/app/views/profile/_request_data_modal.html.erb b/app/views/profile/_request_data_modal.html.erb new file mode 100644 index 000000000..c7756f791 --- /dev/null +++ b/app/views/profile/_request_data_modal.html.erb @@ -0,0 +1,50 @@ + + + + +
+ +
diff --git a/app/views/profile/_subscription_type.html.erb b/app/views/profile/_subscription_type.html.erb index a31af2e90..41c9820b9 100644 --- a/app/views/profile/_subscription_type.html.erb +++ b/app/views/profile/_subscription_type.html.erb @@ -1,32 +1,30 @@
<%= f.form_group :locale, - label: { text: t('profile.locale') } do %> + label: { text: t('profile.locale'), class: 'fw-bold' } do %> <% I18n.available_locales.each do |locale| %> + <% cy_label = "locale-#{locale}-checkbox" %> <%= f.radio_button :locale, locale.to_s, label: t('locales.' + locale.to_s, locale: current_user.locale), - custom: true %> + "data-cy": cy_label %> <% end %> <% end %>
<%= f.form_group :subscription_type, label: { text: t('profile.content_info') } do %> - <%= f.radio_button :subscription_type, 1, - label: t('profile.subscription_mixed'), - checked: @user.subscription_type == 1 || - @user.subscription_type.nil?, - custom: true %> <%= f.radio_button :subscription_type, 2, label: t('profile.subscription_all'), - checked: @user.subscription_type == 2, - custom: true %> + checked: @user.subscription_type == 2 %> <%= f.radio_button :subscription_type, 3, label: t('profile.subscription_strict'), - checked: @user.subscription_type == 3, - custom: true %> + checked: @user.subscription_type == 3 %> + <%= f.radio_button :subscription_type, 1, + label: (t('profile.subscription_mixed') + helpdesk(t('profile.subscription_mixed_example'), false)).html_safe, + checked: @user.subscription_type == 1 || + @user.subscription_type.nil? %> <% end %>
diff --git a/app/views/profile/_subscriptions.html.erb b/app/views/profile/_subscriptions.html.erb index 3e6babe8e..989c679ee 100644 --- a/app/views/profile/_subscriptions.html.erb +++ b/app/views/profile/_subscriptions.html.erb @@ -38,8 +38,8 @@ <% Program.includes(divisions: [courses: [lectures: [:term, :teacher]]]) .select { |p| p.courses.any? }.natural_sort_by(&:name_with_subject) .each do |p| %> -
- -