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/.vscode/settings.json b/.vscode/settings.json index bb386a29a..c9fefa3fe 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -107,6 +107,7 @@ "factorybot", "helpdesk", "katex", + "Timecop", "turbolinks" ] } \ 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..1ab2c3b66 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,14 +34,6 @@ 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: @@ -68,35 +41,35 @@ GEM 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.4) + actionpack (= 7.1.4) + activesupport (= 7.1.4) 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.4) + actionpack (= 7.1.4) + activejob (= 7.1.4) + activerecord (= 7.1.4) + activestorage (= 7.1.4) + activesupport (= 7.1.4) mail (>= 2.7.1) net-imap net-pop net-smtp - actionmailer (7.1.3.4) - actionpack (= 7.1.3.4) - actionview (= 7.1.3.4) - activejob (= 7.1.3.4) - activesupport (= 7.1.3.4) + actionmailer (7.1.4) + actionpack (= 7.1.4) + actionview (= 7.1.4) + activejob (= 7.1.4) + activesupport (= 7.1.4) 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.4) + actionview (= 7.1.4) + activesupport (= 7.1.4) nokogiri (>= 1.8.5) racc rack (>= 2.2.4) @@ -104,15 +77,15 @@ 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.4) + actionpack (= 7.1.4) + activerecord (= 7.1.4) + activestorage (= 7.1.4) + activesupport (= 7.1.4) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.1.3.4) - activesupport (= 7.1.3.4) + actionview (7.1.4) + activesupport (= 7.1.4) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) @@ -124,24 +97,26 @@ GEM 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.4) + activesupport (= 7.1.4) 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.4) + activesupport (= 7.1.4) + activerecord (7.1.4) + activemodel (= 7.1.4) + activesupport (= 7.1.4) timeout (>= 0.4.0) + activerecord-import (1.8.0) + activerecord (>= 4.2) 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) + activestorage (7.1.4) + actionpack (= 7.1.4) + activejob (= 7.1.4) + activerecord (= 7.1.4) + activesupport (= 7.1.4) marcel (~> 1.0) - activesupport (7.1.3.4) + activesupport (7.1.4) base64 bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) @@ -211,7 +186,7 @@ GEM term-ansicolor thor crass (1.0.6) - css_parser (1.17.1) + css_parser (1.19.0) addressable dalli (3.2.8) database_cleaner-active_record (2.2.0) @@ -278,7 +253,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,21 +261,21 @@ 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) 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) @@ -352,7 +327,7 @@ GEM listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) - logger (1.6.0) + logger (1.6.1) loofah (2.22.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) @@ -364,10 +339,10 @@ GEM marcel (1.0.4) mime-types (3.5.2) mime-types-data (~> 3.2015) - mime-types-data (3.2024.0806) + mime-types-data (3.2024.0903) mini_magick (4.13.2) mini_mime (1.1.5) - minitest (5.24.1) + minitest (5.25.1) mobility (1.2.9) i18n (>= 0.6.10, < 2) request_store (~> 1.0) @@ -376,7 +351,7 @@ GEM multipart-post (2.4.1) mustache (1.1.1) mutex_m (0.2.0) - net-imap (0.4.14) + net-imap (0.4.16) date net-protocol net-pop (0.1.2) @@ -399,8 +374,8 @@ 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.5.0) ast (~> 2.4.1) racc pdf-reader (2.12.0) @@ -413,9 +388,9 @@ GEM 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) @@ -431,7 +406,7 @@ GEM public_suffix (6.0.1) puma (6.4.2) nio4r (~> 2.0) - pundit (2.3.2) + pundit (2.4.0) activesupport (>= 3.0.0) raabro (1.4.0) racc (1.8.1) @@ -445,20 +420,20 @@ GEM rackup (1.0.0) 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.4) + actioncable (= 7.1.4) + actionmailbox (= 7.1.4) + actionmailer (= 7.1.4) + actionpack (= 7.1.4) + actiontext (= 7.1.4) + actionview (= 7.1.4) + activejob (= 7.1.4) + activemodel (= 7.1.4) + activerecord (= 7.1.4) + activestorage (= 7.1.4) + activesupport (= 7.1.4) bundler (>= 1.15.0) - railties (= 7.1.3.4) + railties (= 7.1.4) rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest @@ -476,9 +451,9 @@ GEM 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.4) + actionpack (= 7.1.4) + activesupport (= 7.1.4) irb rackup (>= 1.0.0) rake (>= 12.2) @@ -506,8 +481,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.7) rgl (0.6.6) pairing_heap (>= 0.3, < 4.0) rexml (~> 3.2, >= 3.2.4) @@ -520,9 +494,9 @@ 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.1) rspec-support (~> 3.13.0) - rspec-expectations (3.13.1) + rspec-expectations (3.13.2) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) rspec-github (2.4.0) @@ -530,7 +504,7 @@ GEM rspec-mocks (3.13.1) 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 +513,25 @@ GEM rspec-mocks (~> 3.13) rspec-support (~> 3.13) rspec-support (3.13.1) - rubocop (1.65.1) + rubocop (1.66.1) 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.32.2, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 3.0) - rubocop-ast (1.32.0) + rubocop-ast (1.32.3) parser (>= 3.3.1.0) rubocop-performance (1.21.1) rubocop (>= 1.48.1, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rails (2.25.1) + rubocop-rails (2.26.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 +542,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) @@ -590,7 +563,7 @@ GEM shrine (3.6.0) content_disposition (~> 1.0) down (~> 5.1) - sidekiq (7.3.0) + sidekiq (7.3.2) concurrent-ruby (< 2) connection_pool (>= 2.3.0) logger @@ -620,25 +593,31 @@ 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) 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) execjs (>= 0.3.0, < 3) - thor (1.3.1) + thor (1.3.2) tilt (2.4.0) timeago_js (3.0.2.2) + timecop (0.9.10) timeout (0.4.1) tins (1.33.0) bigdecimal @@ -675,99 +654,99 @@ GEM 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/app/assets/javascripts/copy_and_paste_button.js b/app/assets/javascripts/copy_and_paste_button.js index ce43ad7d6..fe0e41bc3 100644 --- a/app/assets/javascripts/copy_and_paste_button.js +++ b/app/assets/javascripts/copy_and_paste_button.js @@ -1,16 +1,28 @@ $(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"); - var id = $(this).data("id"); - $('.token-clipboard-popup[data-id="' + id + '"]').addClass("show"); - var restoreClipboardButton = function () { - $('.token-clipboard-popup[data-id="' + id + '"]').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"); + } - setTimeout(restoreClipboardButton, 1500); + $(popup).addClass("show"); + setTimeout(() => { + $(popup).removeClass("show"); + }, 1700); }); }); diff --git a/app/assets/stylesheets/lectures.scss b/app/assets/stylesheets/lectures.scss index 58a48014c..1476a1c4b 100644 --- a/app/assets/stylesheets/lectures.scss +++ b/app/assets/stylesheets/lectures.scss @@ -128,6 +128,11 @@ h4.lecture-pane-subheader { 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/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/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/vouchers_controller.rb b/app/controllers/vouchers_controller.rb index 87809ee5c..b83d0479a 100644 --- a/app/controllers/vouchers_controller.rb +++ b/app/controllers/vouchers_controller.rb @@ -1,17 +1,14 @@ -# app/controllers/vouchers_controller.rb class VouchersController < ApplicationController include Notifier - before_action :set_voucher, only: [:invalidate] - authorize_resource except: :create + load_and_authorize_resource + before_action :find_voucher, only: :invalidate def current_ability @current_ability ||= VoucherAbility.new(current_user) end def create - @voucher = Voucher.new(voucher_params) set_related_data - authorize! :create, @voucher respond_to do |format| if @voucher.save handle_successful_save(format) @@ -23,7 +20,7 @@ def create def invalidate set_related_data - @voucher.invalidate! + @voucher.update(invalidated_at: Time.zone.now) respond_to do |format| format.html { redirect_to edit_lecture_path(@lecture, anchor: "people") } format.js @@ -54,6 +51,11 @@ def redeem end end + def redeem + # TODO: this will be dealt with in the corresponding 2nd PR + render js: "alert('Voucher redeemed!')" + end + def cancel respond_to do |format| format.html { redirect_to edit_profile_path } @@ -67,17 +69,17 @@ def voucher_params params.permit(:lecture_id, :role) end - def check_voucher_params - params.permit(:secure_hash, tutorial_ids: [], talk_ids: []) - end - - def set_voucher + def find_voucher @voucher = Voucher.find_by(id: params[:id]) return if @voucher handle_voucher_not_found end + def check_voucher_params + params.permit(:secure_hash, tutorial_ids: [], talk_ids: []) + end + def set_related_data @lecture = @voucher.lecture @role = @voucher.role diff --git a/app/models/user.rb b/app/models/user.rb index 35cc87d0b..5040624df 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -125,6 +125,14 @@ class User < ApplicationRecord scope :no_tutorial_name, -> { where(name_in_tutorials: nil) } + # Scopes for usage in the UserCleaner + scope :confirmed, -> { where.not(confirmed_at: nil) } + scope :unconfirmed, -> { where(confirmed_at: nil) } + scope :no_sign_in_data, -> { where(current_sign_in_at: nil) } + scope :active_recently, ->(threshold) { where(current_sign_in_at: threshold.ago..) } + 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 diff --git a/app/models/user_cleaner.rb b/app/models/user_cleaner.rb index a70bb8f7f..566d0c8cd 100644 --- a/app/models/user_cleaner.rb +++ b/app/models/user_cleaner.rb @@ -37,17 +37,30 @@ class UserCleaner # Returns all users who have been inactive for INACTIVE_USER_THRESHOLD months, # i.e. their last sign-in date is more than INACTIVE_USER_THRESHOLD months ago. # - # Users without a last_sign_in_at date are also considered inactive. This is + # Users without a current_sign_in_at date are also considered inactive. This is # the case for users who have never logged in since PR #553 was merged. + # + # Edge cases for registration (that refine the above statements): + # - A user might have registered but never actually logged in (confirmed their + # email address). In this case, we don't look at the current_sign_in_at date + # (as this one is still nil), but at the confirmation_sent_at date to + # determine if the user is considered inactive. + # - Another edge case is when users have registered and confirmed their mail, + # but never logged in after that. In this case, current_sign_in_at is indeed nil, + # but the user should only be considered inactive if the confirmation_sent_at + # date is older than the threshold. def inactive_users - User.where(last_sign_in_at: ...INACTIVE_USER_THRESHOLD.ago) - .or(User.where(last_sign_in_at: nil)) + threshold = INACTIVE_USER_THRESHOLD + User.confirmed.and( + User.inactive_for(threshold) + .or(User.no_sign_in_data.confirmation_sent_before(threshold)) + ).or(User.unconfirmed.confirmation_sent_before(threshold)) end # Returns all users who have been active in the last INACTIVE_USER_THRESHOLD months, # i.e. their last sign-in date is less than INACTIVE_USER_THRESHOLD months ago. def active_users - User.where(last_sign_in_at: INACTIVE_USER_THRESHOLD.ago..) + User.active_recently(INACTIVE_USER_THRESHOLD) end # Sets the deletion date for inactive users and sends an initial warning mail. diff --git a/app/models/voucher.rb b/app/models/voucher.rb index 45a7f9584..ad4c4a7f1 100644 --- a/app/models/voucher.rb +++ b/app/models/voucher.rb @@ -1,19 +1,18 @@ class Voucher < ApplicationRecord - ROLE_HASH = { tutor: 0, editor: 1, teacher: 2, speaker: 3 }.freeze 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 - has_many :redemptions, dependent: :destroy + 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 - validates :role, presence: true scope :active, lambda { where("expires_at > ? AND invalidated_at IS NULL", @@ -23,7 +22,7 @@ class Voucher < ApplicationRecord scope :for_editors, -> { where(role: :editor) } scope :for_speakers, -> { where(role: :speaker) } - self.implicit_order_column = "created_at" + self.implicit_order_column = :created_at def self.roles_for_lecture(lecture) return ROLE_HASH.keys if lecture.seminar? 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/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 8d3e36753..d6d254d1d 100644 --- a/app/views/lectures/edit/_form.html.erb +++ b/app/views/lectures/edit/_form.html.erb @@ -121,6 +121,7 @@
<%= render partial: 'lectures/edit/announcements', diff --git a/app/views/lectures/edit/_vouchers.html.erb b/app/views/lectures/edit/_vouchers.html.erb index 3fa33f47f..8c9b2582e 100644 --- a/app/views/lectures/edit/_vouchers.html.erb +++ b/app/views/lectures/edit/_vouchers.html.erb @@ -1,19 +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| %> -

- <%= t("basics.voucher_for", - role: t("basics.#{role}s")) %> - <%= helpdesk(t("admin.lecture.info.voucher_for_#{role}"), false) %> -

-
- <%= render partial: 'vouchers/voucher', - locals: { lecture: lecture, - role: role } %> -
+
+
+ <%= render partial: 'vouchers/voucher' , locals: { lecture: lecture, role: role } %> +
+
<% end %> -<% end %> \ No newline at end of file +
+<% end %> 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 @@ - + +
+ <%= 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/vouchers/_voucher.html.erb b/app/views/vouchers/_voucher.html.erb index 98348867d..6e87d2d06 100644 --- a/app/views/vouchers/_voucher.html.erb +++ b/app/views/vouchers/_voucher.html.erb @@ -1,58 +1,70 @@ <% voucher = lecture.active_voucher_of_role(role) %> -<% if voucher %> -
- - <%= t('admin.voucher.secure_hash') %>: - - <%= voucher.secure_hash %> - - - - - <%= t('basics.code_copied_to_clipboard') %> - - - <%= link_to invalidate_voucher_path(voucher), - class: 'text-dark ms-2', - data: { toggle: 'tooltip', - placement: 'bottom', - confirm: t('confirmation.generic'), - cy: "invalidate-#{role}-voucher-btn" }, - style: 'text-decoration: none;', - title: t('buttons.invalidate'), - method: :post, - remote: true do %> - - - <% end %> +
+ +

+ <%= t("basics.voucher_for", role: t("basics.#{role}s")) %> +

+ + <% if voucher %> + +
+ + +
+ + <% else %> +

- <%= t('admin.voucher.expires_at') %>: - <%= l(voucher.expires_at, format: :long) %> + <%= t('admin.lecture.no_active_voucher', + role: t("basics.voucher_for", role: t("basics.#{role}s"))) %>

+ <% end %> -
-<% else %> -

- <%= t('admin.lecture.no_active_voucher', - role: t("basics.voucher_for", role: t("basics.#{role}s"))) %> - <%= link_to vouchers_path(params: { lecture_id: lecture.id, - role: role }), - class: 'text-dark ms-2', - data: { toggle: 'tooltip', - placement: 'bottom', - cy: "create-#{role}-voucher-btn" }, - title: t('buttons.create_voucher'), - method: :post, - remote: true do %> - + + <% if voucher %> + <%= link_to invalidate_voucher_path(voucher), + class: 'btn btn-outline-danger', + data: { toggle: 'tooltip', placement: 'bottom', + confirm: t('admin.voucher.sure_to_delete'), + cy: "invalidate-#{role}-voucher-btn" }, + style: 'text-decoration: none;', + method: :post, remote: true do %> + + <%= t('buttons.invalidate') %> + <% end %> + <% else %> + <%= link_to vouchers_path(params: { lecture_id: lecture.id, role: role }), + class: 'btn btn-outline-primary', + data: {toggle: 'tooltip', placement: 'bottom', cy: "create-#{role}-voucher-btn"}, + method: :post, remote: true do %> + + <%= t('buttons.create_voucher') %> <% end %> -

+ <% end %> + +
+ + +<% if voucher %> + <% end %> diff --git a/config/database.yml b/config/database.yml index 747a89c11..964d81a7d 100644 --- a/config/database.yml +++ b/config/database.yml @@ -1,9 +1,3 @@ -# SQLite version 3.x -# gem install sqlite3 -# -# Ensure the SQLite 3 gem is defined in your Gemfile -# gem 'sqlite3' -# default: &default adapter: postgresql pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> @@ -74,8 +68,6 @@ test: migrations_paths: db/interactions_migrate production: - # <<: *default - # database: db/development.sqlite3 primary: adapter: <%= ENV['PRODUCTION_DATABASE_ADAPTER'] ||= 'postgresql' %> encoding: <%= ENV['PRODUCTION_DATABASE_ENCODING'] %> diff --git a/config/locales/de.yml b/config/locales/de.yml index 90b0e081b..066b087e8 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -78,7 +78,7 @@ de: Deiner Profilseite Zugriff erhalten. footer: text_html: > - © MaMpf Team 2023. MaMpf ist auf %{github}. + © MaMpf Team 2024. MaMpf ist auf %{github}. Powered by %{rails}, %{bootstrap}, %{cytoscape}, %{nerdamer}, %{katex}, %{thredded}, %{docker}, %{basecamp}. Bugs meldest Du am besten %{bugs}. @@ -497,6 +497,8 @@ de: import_chapters: Kapitel importieren import_sections: Abschnitte importieren import_tags: Tags importieren + no_active_voucher: > + Es gibt keinen aktiven %{role} für diese Veranstaltung. no_active_voucher: > Es gibt keinen aktiven %{role} für diese Veranstaltung. info: @@ -2113,6 +2115,13 @@ de: voucher: secure_hash: 'Sicherheits-Hash' expires_at: 'Läuft ab' + sure_to_delete: 'Bist Du sicher, dass Du diesen Gutschein löschen möchtest?' + general_explanation: > + Wenn Du jemandem eine Rolle zuweisen möchtest (z.B. jemanden zum Tutor machen), + kannst Du hier einen Gutschein erstellen. Dies ist ein spezieller Code, + den die Person in ihrem Profil einlösen kann, um die Rolle zu erhalten. + Du solltest den Code über ein anderes Medium an die Person senden, + z.B. per E-Mail. profile: account: 'Account' name: 'Anzeigename' @@ -3627,7 +3636,7 @@ de: editors: EditorInnen vouchers: Gutscheine voucher_for: Gutschein für %{role} - speaker: VortragendeR + speaker: Vortragender speakers: Vortragende access: unpublished: 'unveröffentlicht' @@ -3737,9 +3746,9 @@ de: import_lecture_toc: 'Gliederung importieren' import_toc: 'Importieren' become_editor: 'EditorIn werden' - invalidate: 'Für ungültig erklären' verify_voucher: 'Gutschein prüfen' edit_talk: 'Vortrag bearbeiten' + invalidate: 'Für ungültig erklären' warnings: save_before: > Du musst zunächst die anderen Änderungen speichern oder verwerfen. diff --git a/config/locales/en.yml b/config/locales/en.yml index 9aecbbf4a..251b1cf75 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -101,7 +101,7 @@ en: You may get access by modifying your account settings. footer: text_html: > - © MaMpf Team 2023. MaMpf is on %{github}. + © MaMpf Team 2024. MaMpf is on %{github}. Powered by %{rails}, %{bootstrap}, %{cytoscape}, %{nerdamer}, %{katex}, %{thredded}, %{docker}, %{basecamp}. Bugs can be reported %{bugs}. @@ -496,6 +496,8 @@ en: import_chapters: import chapters import_sections: import sections import_tags: import tags + no_active_voucher: > + There is no active %{role} for this event series. no_active_voucher: > There is no active %{role} for this event series. info: @@ -1981,6 +1983,12 @@ en: voucher: secure_hash: 'Secure Hash' expires_at: 'Expires at' + sure_to_delete: 'Are you sure you want to delete this voucher?' + general_explanation: > + If you want to assign somebody a role (e.g. make them a tutor), you can + create a voucher. This is a special code that the person can redeem in + their profile to get the role, e.g. to become a tutor for your lecture. + You should send the code to the person via a different medium, e.g. email. profile: account: 'Account' name: 'Display name' @@ -3535,9 +3543,9 @@ en: import_lecture_toc: 'Import TOC' import_toc: 'Import' become_editor: 'Become Editor' - invalidate: 'Invalidate' verify_voucher: 'Verify Voucher' edit_talk: 'Edit Talk' + invalidate: 'Invalidate' confirmation: generic: 'Are you sure?' delete_graph: > diff --git a/config/routes.rb b/config/routes.rb index e547d5641..7ffc03582 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -14,6 +14,8 @@ resources :database_cleaner, only: :create resources :user_creator, only: :create resources :i18n, only: :create + post "timecop/travel", to: "timecop#travel" + post "timecop/reset", to: "timecop#reset" end end diff --git a/db/migrate/20240906200000_create_vouchers.rb b/db/migrate/20240906200000_create_vouchers.rb new file mode 100644 index 000000000..274145b7c --- /dev/null +++ b/db/migrate/20240906200000_create_vouchers.rb @@ -0,0 +1,13 @@ +class CreateVouchers < ActiveRecord::Migration[7.1] + def change + create_table :vouchers, id: :uuid do |t| + t.integer :role, null: false + t.references :lecture, null: false, foreign_key: true + t.string :secure_hash, null: false + t.datetime :invalidated_at + t.datetime :expires_at + t.timestamps + end + add_index :vouchers, :secure_hash, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 5a10ee3e5..de6c72b2d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2024_08_16_150011) do +ActiveRecord::Schema[7.1].define(version: 2024_09_06_200000) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" diff --git a/docker/development/Dockerfile b/docker/development/Dockerfile index 4aa9b05c7..8b6201bd7 100644 --- a/docker/development/Dockerfile +++ b/docker/development/Dockerfile @@ -53,7 +53,7 @@ RUN yarn set version "${YARN_VERSION}" RUN apt update && \ apt-get install -y --no-install-recommends \ ffmpeg imagemagick pdftk ghostscript shared-mime-info \ - libarchive-tools postgresql-client-13 sqlite3 wget wait-for-it + libarchive-tools postgresql-client-13 wget wait-for-it # Setup ImageMagick RUN sed -i '/disable ghostscript format types/,+6d' /etc/ImageMagick-6/policy.xml diff --git a/docker/test/Dockerfile b/docker/test/Dockerfile index 470e065f5..32bf1eb51 100644 --- a/docker/test/Dockerfile +++ b/docker/test/Dockerfile @@ -52,7 +52,7 @@ RUN yarn set version "${YARN_VERSION}" RUN apt update && \ apt-get install -y --no-install-recommends \ ffmpeg imagemagick pdftk ghostscript shared-mime-info \ - libarchive-tools postgresql-client-13 sqlite3 wget wait-for-it + libarchive-tools postgresql-client-13 wget wait-for-it # Setup ImageMagick RUN sed -i '/disable ghostscript format types/,+6d' /etc/ImageMagick-6/policy.xml diff --git a/spec/controllers/vouchers_controller_spec.rb b/spec/controllers/vouchers_controller_spec.rb index e5c00aa4e..61b01b87e 100644 --- a/spec/controllers/vouchers_controller_spec.rb +++ b/spec/controllers/vouchers_controller_spec.rb @@ -28,7 +28,7 @@ end end - context "As a generic user" do + context "As an unauthorized user" do before do sign_in generic_user end diff --git a/spec/cypress/e2e/annotations_overview_spec.cy.js b/spec/cypress/e2e/annotations_overview_spec.cy.js index f4c2f9561..2dea3b189 100644 --- a/spec/cypress/e2e/annotations_overview_spec.cy.js +++ b/spec/cypress/e2e/annotations_overview_spec.cy.js @@ -23,9 +23,9 @@ function createAnnotationScenario(context, userRole = "student") { cy.then(() => { // a user is considered a teacher only iff they have given any lecture const teacherUser = userRole === "teacher" ? context.user : context.teacherUser; - FactoryBot.create("lecture_with_sparse_toc", "with_title", "with_teacher_by_id", + FactoryBot.create("lecture_with_sparse_toc", "with_title", { title: LECTURE_TITLE_1, teacher_id: teacherUser.id }).as("lectureSage"); - FactoryBot.create("lecture_with_sparse_toc", "with_title", "with_teacher_by_id", + FactoryBot.create("lecture_with_sparse_toc", "with_title", { title: LECTURE_TITLE_2, teacher_id: teacherUser.id }).as("lectureLean"); }); @@ -80,7 +80,7 @@ describe("Annotation section", () => { cy.createUserAndLogin("teacher").as("teacher"); cy.then(() => { // a user is considered a teacher only iff they have given any lecture - FactoryBot.create("lecture", "with_teacher_by_id", { teacher_id: this.teacher.id }); + FactoryBot.create("lecture", { teacher_id: this.teacher.id }); }); cy.i18n("admin.annotation.your_annotations").as("yourAnnotations"); diff --git a/spec/cypress/e2e/annotations_spec.cy.js b/spec/cypress/e2e/annotations_spec.cy.js new file mode 100644 index 000000000..498f98465 --- /dev/null +++ b/spec/cypress/e2e/annotations_spec.cy.js @@ -0,0 +1,71 @@ +import FactoryBot from "../support/factorybot"; + +function createLectureLessonMedium(context, teacher) { + // Lecture + FactoryBot.create("lecture_with_sparse_toc", "with_title", + { title: "Groundbreaking lecture", teacher_id: teacher.id }).as("lecture"); + + // Lesson + cy.then(() => { + FactoryBot.create("valid_lesson", { lecture_id: context.lecture.id }).as("lesson"); + }); + + // Medium + cy.then(() => { + FactoryBot.create("lesson_medium", "with_video", "released", + "with_lesson_by_id", { lesson_id: context.lesson.id, description: "Soil medium" }) + .as("medium"); + }); +} + +describe("Annotations visibility", () => { + context("when teacher disables annotation sharing with teachers", () => { + it("annotations published before that are still visible to the teacher", function () { + cy.createUser("generic").as("user"); + cy.createUserAndLogin("teacher").then(teacher => createLectureLessonMedium(this, teacher)); + + cy.then(() => { + // Create new annotation + FactoryBot.create("annotation", "with_text", "shared_with_teacher", + { medium_id: this.medium.id, user_id: this.user.id }).as("annotation"); + + // Disable annotation sharing in lecture settings + cy.visit(`/lectures/${this.lecture.id}/edit#communication`); + cy.getBySelector("annotation-lecture-settings") + .should("be.visible") + .find("input[value=0]").should("have.length", 1).click(); + + // Click on submit button to save changes + cy.intercept("POST", `/lectures/${this.lecture.id}`).as("lectureUpdate"); + cy.getBySelector("lecture-pane-communication") + .find("input[type=submit]").should("have.length", 1).click(); + cy.wait("@lectureUpdate"); + + // Make sure that changes were really saved + cy.reload(); + cy.getBySelector("annotation-lecture-settings") + .should("be.visible").then(($form) => { + cy.wrap($form).find("input[value=0]").should("be.checked"); + cy.wrap($form).find("input[value=1]").should("not.be.checked"); + }); + }); + + cy.then(() => { + cy.visit(`/media/${this.medium.id}/feedback`); + + // Annotation is visible + cy.getBySelector("feedback-markers") + .children().should("have.length", 1) + .click({ force: true }); + + // Annotation can be opened in sidebar + cy.getBySelector("annotation-caption").then(($sideBar) => { + cy.i18n(`admin.annotation.${this.annotation.category}`).then((category) => { + cy.wrap($sideBar).children().first().should("contain", category); + }); + cy.wrap($sideBar).children().eq(1).should("contain", this.annotation.comment); + }); + }); + }); + }); +}); diff --git a/spec/cypress/e2e/lectures_spec.cy.js b/spec/cypress/e2e/lectures_spec.cy.js index 96d3994ee..59b59cbb2 100644 --- a/spec/cypress/e2e/lectures_spec.cy.js +++ b/spec/cypress/e2e/lectures_spec.cy.js @@ -3,7 +3,7 @@ import FactoryBot from "../support/factorybot"; describe("Lecture edit page", () => { it("shows content tab button", function () { cy.createUserAndLogin("teacher").then((teacher) => { - FactoryBot.create("lecture", "with_teacher_by_id", + FactoryBot.create("lecture", { teacher_id: teacher.id }).as("lecture"); }); diff --git a/spec/cypress/e2e/vouchers_spec.cy.js b/spec/cypress/e2e/vouchers_spec.cy.js index 6264329bc..c667748f2 100644 --- a/spec/cypress/e2e/vouchers_spec.cy.js +++ b/spec/cypress/e2e/vouchers_spec.cy.js @@ -1,7 +1,8 @@ import FactoryBot from "../support/factorybot"; +import Timecop from "../support/timecop"; const ROLES = ["tutor", "editor", "teacher", "speaker"]; -const NO_SEMINAR_ROLES = ROLES.filter(role => role !== "speaker"); +const ROLES_WITHOUT_SEMINAR = ROLES.filter(role => role !== "speaker"); function createLectureScenario(context, type = "lecture") { cy.createUserAndLogin("teacher").as("teacher"); @@ -11,37 +12,38 @@ function createLectureScenario(context, type = "lecture") { }); cy.then(() => { - cy.visit(`/lectures/${context.lecture.id}/edit`); - cy.getBySelector("people-tab-btn").click(); + cy.visit(`/lectures/${context.lecture.id}/edit#people`); + cy.getBySelector("vouchers-header").should("be.visible"); }); cy.i18n("basics.vouchers").as("vouchers"); } +function assertVoucherShown(role) { + cy.getBySelector(`create-${role}-voucher-btn`).should("not.exist"); + cy.getBySelector(`invalidate-${role}-voucher-btn`).should("be.visible"); + cy.getBySelector(`${role}-voucher-secure-hash`) + .invoke("val").should("match", /^([a-z0-9]){32}$/); +} + +function assertVoucherNotShown(role) { + cy.getBySelector(`invalidate-${role}-voucher-btn`).should("not.exist"); + cy.getBySelector(`create-${role}-voucher-btn`).should("be.visible"); + cy.getBySelector(`${role}-voucher-secure-hash`).should("not.exist"); +} + function testCreateVoucher(role) { cy.getBySelector(`create-${role}-voucher-btn`).click(); - - cy.then(() => { - cy.getBySelector(`${role}-voucher-data`).should("be.visible"); - cy.getBySelector(`${role}-voucher-secure-hash`).should("not.be.empty"); - cy.getBySelector(`invalidate-${role}-voucher-btn`).should("be.visible"); - }); + assertVoucherShown(role); } function testInvalidateVoucher(role) { cy.getBySelector(`invalidate-${role}-voucher-btn`).click(); - - // Confirm popup - cy.on("window:confirm", () => true); - - cy.then(() => { - cy.getBySelector(`${role}-voucher-data`).should("not.exist"); - cy.getBySelector(`invalidate-${role}-voucher-btn`).should("not.exist"); - cy.getBySelector(`create-${role}-voucher-btn`).should("be.visible"); - }); + cy.on("window:confirm", () => true); // Confirm popup + assertVoucherNotShown(role); } -describe("If the lecture is not a seminar", () => { +context("When the lecture is not a seminar", () => { beforeEach(function () { createLectureScenario(this); }); @@ -50,7 +52,7 @@ describe("If the lecture is not a seminar", () => { it("shows buttons for creating tutor, editor and teacher vouchers", function () { cy.contains(this.vouchers).should("be.visible"); - NO_SEMINAR_ROLES.forEach((role) => { + ROLES_WITHOUT_SEMINAR.forEach((role) => { cy.getBySelector(`create-${role}-voucher-btn`).should("be.visible"); }); @@ -58,20 +60,20 @@ describe("If the lecture is not a seminar", () => { }); it("displays the voucher and invalidate button after the create button is clicked", function () { - NO_SEMINAR_ROLES.forEach((role) => { + ROLES_WITHOUT_SEMINAR.forEach((role) => { testCreateVoucher(role); }); }); it("displays that there is no active voucher after the invalidate button is clicked", function () { - NO_SEMINAR_ROLES.forEach((role) => { + ROLES_WITHOUT_SEMINAR.forEach((role) => { testCreateVoucher(role); testInvalidateVoucher(role); }); }); it.skip("copies the voucher hash to the clipboard", function () { - NO_SEMINAR_ROLES.forEach((role) => { + ROLES_WITHOUT_SEMINAR.forEach((role) => { cy.getBySelector(`create-${role}-voucher-btn`).click(); cy.getBySelector(`${role}-voucher-secure-hash`).then(($hash) => { const hashText = $hash.text(); @@ -83,7 +85,7 @@ describe("If the lecture is not a seminar", () => { }); }); -describe("If the lecture is a seminar", () => { +context("When the lecture is a seminar", () => { beforeEach(function () { createLectureScenario(this, "seminar"); }); @@ -110,3 +112,56 @@ describe("If the lecture is a seminar", () => { }); }); }); + +context("When traveling into the future", () => { + beforeEach(function () { + createLectureScenario(this, "seminar"); + }); + + afterEach(() => { + Timecop.reset(); + }); + + it("does not show expired vouchers (far in the future)", function () { + ROLES.forEach((role) => { + testCreateVoucher(role); + }); + + // This behavior is more extensively tested via unit tests in the backend. + // This is just a sanity check where we travel *far* into the future. + Timecop.moveAheadDays(1000).then(() => { + cy.reload(); + ROLES.forEach((role) => { + assertVoucherNotShown(role); + }); + }); + }); + + it("does not show expired vouchers (near future)", function () { + ROLES.forEach((role) => { + testCreateVoucher(role); + textExpiresAtWithTimeTravel(role); + }); + + function textExpiresAtWithTimeTravel(role) { + // find date string, read it, then travel to that date (+1 minute) + cy.getBySelector(`${role}-voucher-expires-at`).then(($expiresAt) => { + const date = new Date($expiresAt.text()); + date.setMinutes(date.getMinutes() + 1); + cy.isValidDate(date).then((isValid) => { + expect(isValid).to.be.true; + }); + + cy.log(`Traveling to ${date.toISOString()} (UTC)`); + Timecop.travelToDate(date, true); + }); + + cy.then(() => { + cy.reload(); + assertVoucherNotShown(role); + }); + + Timecop.reset(); + } + }); +}); diff --git a/spec/cypress/support/commands.js b/spec/cypress/support/commands.js index 0cb23c707..96230af9c 100644 --- a/spec/cypress/support/commands.js +++ b/spec/cypress/support/commands.js @@ -67,6 +67,11 @@ Cypress.Commands.add("assertCopiedToClipboard", (_expectedText) => { // .should("equal", expectedText); }); +Cypress.Commands.add("isValidDate", (date) => { + // https://stackoverflow.com/a/1353711/ + return date instanceof Date && !isNaN(date); +}); + //////////////////////////////////////////////////////////////////////////////// // Custom commands for backend interaction //////////////////////////////////////////////////////////////////////////////// diff --git a/spec/cypress/support/timecop.js b/spec/cypress/support/timecop.js new file mode 100644 index 000000000..9d6dd5926 --- /dev/null +++ b/spec/cypress/support/timecop.js @@ -0,0 +1,59 @@ +import BackendCaller from "./backend_caller"; + +/** + * Helper to call Timecop from Cypress tests, which is used to freeze or travel + * time in the backend. + * + * This is different from cy.clock() which only affects the frontend. + */ +class Timecop { + /** + * Travels to the given date in the backend. + * + * By default, the date is assumed to be in the local timezone (assuming the + * backend is configured to use local time). + */ + #travelTo(year, month, day, hours = 0, minutes = 0, seconds = 0, useUTC = false) { + return BackendCaller.callCypressRoute("timecop/travel", "Timecop.travel()", + { year: year, month: month, day: day, + hours: hours, minutes: minutes, seconds: seconds, + use_utc: useUTC, + }); + } + + /** + * Travels to the given date in the backend. + * + * By default, the date is assumed to be in the local timezone (assuming the + * backend is configured to use local time). + */ + travelToDate(date, useUTC = false) { + return this.#travelTo( + date.getFullYear(), date.getMonth() + 1, date.getDate(), + date.getHours(), date.getMinutes(), date.getSeconds(), + useUTC, + ); + } + + /** + * Moves the time ahead by the given number of days. + */ + moveAheadDays(days) { + const now = new Date(); + now.setDate(now.getDate() + days); + cy.log(`Moving ahead ${days} days to ${now.toISOString()}`); + return this.#travelTo( + now.getFullYear(), now.getMonth() + 1, now.getDate(), + now.getHours(), now.getMinutes(), now.getSeconds(), + ); + } + + /** + * Resets the time in the backend to the current time. + */ + reset() { + return BackendCaller.callCypressRoute("timecop/reset", "Timecop.reset()"); + } +} + +export default new Timecop(); diff --git a/spec/factories/lectures.rb b/spec/factories/lectures.rb index ae0274c8c..c47b26b49 100644 --- a/spec/factories/lectures.rb +++ b/spec/factories/lectures.rb @@ -62,13 +62,6 @@ course { association :course, title: title } end - trait :with_teacher_by_id do - transient do - teacher_id { nil } - end - teacher { User.find(teacher_id) } - end - # NOTE: that you can give the chapter_count here as parameter as well factory :lecture_with_toc, traits: [:with_toc] diff --git a/spec/factories/users.rb b/spec/factories/users.rb index b9affdd33..4c7633ac8 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -17,6 +17,16 @@ after(:create, &:confirm) end + trait :with_confirmation_sent_date do + transient do + confirmation_sent_date { Time.zone.now } + end + + after(:create) do |user, context| + user.update(confirmation_sent_at: context.confirmation_sent_date) + end + end + trait :consented do after(:create) do |user| user.update(consents: true, consented_at: Time.zone.now) diff --git a/spec/factories/vouchers.rb b/spec/factories/vouchers.rb index 1242923c4..9605883bb 100644 --- a/spec/factories/vouchers.rb +++ b/spec/factories/vouchers.rb @@ -15,6 +15,10 @@ role { :teacher } end + trait :speaker do + role { :speaker } + end + trait :expired do after(:create) do |voucher| voucher.update(expires_at: 1.day.ago) diff --git a/spec/models/course_spec.rb b/spec/models/course_spec.rb index 353fe81db..da0911427 100644 --- a/spec/models/course_spec.rb +++ b/spec/models/course_spec.rb @@ -546,6 +546,7 @@ self_item1 = Item.find_by(sort: "self", medium: @course_medium) self_item2 = Item.find_by(sort: "self", medium: @lecture_medium) self_item3 = Item.find_by(sort: "self", medium: @lesson_medium) + I18n.locale = :de expect(@course.media_items_with_inheritance) .to match_array([["Bem. 1.2 ", item1.id], ["SS 20, Satz 3.4 ", item2.id], diff --git a/spec/models/user_cleaner_spec.rb b/spec/models/user_cleaner_spec.rb index 689962e3e..1223a9048 100644 --- a/spec/models/user_cleaner_spec.rb +++ b/spec/models/user_cleaner_spec.rb @@ -3,17 +3,17 @@ RSpec.describe(UserCleaner, type: :model) do # Non-generic users are either admins, teachers or editors let(:user_admin) do - return FactoryBot.create(:user, deletion_date: Date.current - 1.day, admin: true) + return FactoryBot.create(:confirmed_user, deletion_date: Date.current - 1.day, admin: true) end let(:user_teacher) do - user_teacher = FactoryBot.create(:user, deletion_date: Date.current - 1.day) + user_teacher = FactoryBot.create(:confirmed_user, deletion_date: Date.current - 1.day) FactoryBot.create(:lecture, teacher: user_teacher) return user_teacher end let(:user_editor) do - user_editor = FactoryBot.create(:user, deletion_date: Date.current - 1.day) + user_editor = FactoryBot.create(:confirmed_user, deletion_date: Date.current - 1.day) FactoryBot.create(:lecture, editors: [user_editor]) return user_editor end @@ -23,33 +23,74 @@ end describe("#inactive_users") do - it "counts users without last_sign_in_at date as inactive" do - FactoryBot.create(:user, last_sign_in_at: nil) - expect(UserCleaner.new.inactive_users.count).to eq(1) - end + context "when user is confirmed" do + it "counts users without current_sign_in_at date as inactive" do + # but only if also the confirmation date is older than the threshold + FactoryBot.create(:confirmed_user, :with_confirmation_sent_date, + confirmation_sent_date: 5.months.ago, current_sign_in_at: nil) + FactoryBot.create(:confirmed_user, :with_confirmation_sent_date, + confirmation_sent_date: 7.months.ago, current_sign_in_at: nil) + expect(UserCleaner.new.inactive_users.count).to eq(1) + end - it("counts users with last_sign_in_at date older than threshold as inactive") do - FactoryBot.create(:user, last_sign_in_at: 7.months.ago) - expect(UserCleaner.new.inactive_users.count).to eq(1) + it("counts users with current_sign_in_at date older than threshold as inactive") do + FactoryBot.create(:confirmed_user, current_sign_in_at: 7.months.ago) + expect(UserCleaner.new.inactive_users.count).to eq(1) + end + + it "does not count users with current_sign_in_at date younger than threshold as inactive" do + FactoryBot.create(:confirmed_user, current_sign_in_at: 5.months.ago) + expect(UserCleaner.new.inactive_users.count).to eq(0) + end end - it "does not count users with last_sign_in_at date younger than threshold as inactive" do - FactoryBot.create(:user, last_sign_in_at: 5.months.ago) - expect(UserCleaner.new.inactive_users.count).to eq(0) + context "when user is not confirmed yet" do + def test_non_confirmed_user(confirmation_sent_date, expected_inactive_users_count) + user = FactoryBot.create(:user, :with_confirmation_sent_date, + confirmation_sent_date: confirmation_sent_date, + current_sign_in_at: nil) + FactoryBot.create(:user, :with_confirmation_sent_date, + confirmation_sent_date: confirmation_sent_date, + current_sign_in_at: 5.months.ago) + FactoryBot.create(:user, :with_confirmation_sent_date, + confirmation_sent_date: confirmation_sent_date, + current_sign_in_at: 7.months.ago) + + expect(user.confirmed_at).to be_nil + expect(user.confirmation_sent_at).to eq(confirmation_sent_date) + + expect(UserCleaner.new.inactive_users.count).to eq(expected_inactive_users_count) + end + + context "when registration was recently" do + it "does not count user as inactive regardless of value of last_sign_in_date" do + test_non_confirmed_user(5.days.ago, 0) + end + end + + context "when registration was long ago" do + it "counts users as inactive regardless of value of last_sign_in_date" do + test_non_confirmed_user(7.months.ago, 3) + end + end end end describe("#set/unset_deletion_date") do context "when deletion date is nil" do it "assigns a deletion date to inactive users" do - inactive_user = FactoryBot.create(:user, last_sign_in_at: 7.months.ago) - active_user = FactoryBot.create(:user, last_sign_in_at: 5.months.ago) + inactive_user = FactoryBot.create(:confirmed_user, current_sign_in_at: 7.months.ago) + inactive_user2 = FactoryBot.create(:user, :with_confirmation_sent_date, + confirmation_sent_date: 7.months.ago) + active_user = FactoryBot.create(:confirmed_user, current_sign_in_at: 5.months.ago) UserCleaner.new.set_deletion_date_for_inactive_users inactive_user.reload + inactive_user2.reload active_user.reload expect(inactive_user.deletion_date).to eq(Date.current + 40.days) + expect(inactive_user2.deletion_date).to eq(Date.current + 40.days) expect(active_user.deletion_date).to be_nil end @@ -57,7 +98,8 @@ max_deletions = 3 UserCleaner::MAX_DELETIONS_PER_RUN = max_deletions - FactoryBot.create_list(:user, max_deletions + 2, last_sign_in_at: 7.months.ago) + FactoryBot.create_list(:confirmed_user, max_deletions + 2, + current_sign_in_at: 7.months.ago) UserCleaner.new.set_deletion_date_for_inactive_users @@ -68,24 +110,29 @@ context "when a deletion date is assigned" do it "does not overwrite the deletion date" do - user = FactoryBot.create(:user, last_sign_in_at: 7.months.ago, - deletion_date: Date.current + 42.days) + user = FactoryBot.create(:confirmed_user, current_sign_in_at: 7.months.ago, + deletion_date: Date.current + 42.days) + user2 = FactoryBot.create(:user, :with_confirmation_sent_date, + confirmation_sent_date: 7.months.ago, + deletion_date: Date.current + 44.days) UserCleaner.new.set_deletion_date_for_inactive_users user.reload + user2.reload expect(user.deletion_date).to eq(Date.current + 42.days) + expect(user2.deletion_date).to eq(Date.current + 44.days) end end it "unassigns a deletion date from recently active users" do deletion_date = Date.current + 5.days - user_inactive = FactoryBot.create(:user, deletion_date: deletion_date, - last_sign_in_at: 7.months.ago) - user_inactive2 = FactoryBot.create(:user, deletion_date: deletion_date, - last_sign_in_at: 6.months.ago - 1.day) - user_active = FactoryBot.create(:user, deletion_date: deletion_date, - last_sign_in_at: 2.days.ago) + user_inactive = FactoryBot.create(:confirmed_user, deletion_date: deletion_date, + current_sign_in_at: 7.months.ago) + user_inactive2 = FactoryBot.create(:confirmed_user, deletion_date: deletion_date, + current_sign_in_at: 6.months.ago - 1.day) + user_active = FactoryBot.create(:confirmed_user, deletion_date: deletion_date, + current_sign_in_at: 2.days.ago) UserCleaner.new.unset_deletion_date_for_recently_active_users user_inactive.reload @@ -100,9 +147,9 @@ describe("#delete_users") do it "deletes users with a deletion date in the past or present" do - user_past1 = FactoryBot.create(:user, deletion_date: Date.current - 1.day) - user_past2 = FactoryBot.create(:user, deletion_date: Date.current - 1.year) - user_present = FactoryBot.create(:user, deletion_date: Date.current) + user_past1 = FactoryBot.create(:confirmed_user, deletion_date: Date.current - 1.day) + user_past2 = FactoryBot.create(:confirmed_user, deletion_date: Date.current - 1.year) + user_present = FactoryBot.create(:confirmed_user, deletion_date: Date.current) UserCleaner.new.delete_users_according_to_deletion_date! @@ -112,8 +159,8 @@ end it "does not delete users with a deletion date in the future" do - user_future1 = FactoryBot.create(:user, deletion_date: Date.current + 1.day) - user_future2 = FactoryBot.create(:user, deletion_date: Date.current + 1.year) + user_future1 = FactoryBot.create(:confirmed_user, deletion_date: Date.current + 1.day) + user_future2 = FactoryBot.create(:confirmed_user, deletion_date: Date.current + 1.year) UserCleaner.new.delete_users_according_to_deletion_date! @@ -122,13 +169,13 @@ end it "does not delete users without a deletion date" do - user = FactoryBot.create(:user, deletion_date: nil) + user = FactoryBot.create(:confirmed_user, deletion_date: nil) UserCleaner.new.delete_users_according_to_deletion_date! expect(User.where(id: user.id)).to exist end it "deletes only generic users" do - user_generic = FactoryBot.create(:user, deletion_date: Date.current - 1.day) + user_generic = FactoryBot.create(:confirmed_user, deletion_date: Date.current - 1.day) user_admin user_teacher user_editor @@ -145,7 +192,7 @@ describe("mails") do context "when setting a deletion date" do it "enqueues a deletion warning mail (40 days)" do - FactoryBot.create(:user, last_sign_in_at: 7.months.ago) + FactoryBot.create(:confirmed_user, current_sign_in_at: 7.months.ago) expect do UserCleaner.new.set_deletion_date_for_inactive_users @@ -166,7 +213,7 @@ context "when a deletion date is assigned" do def test_enqueues_additional_deletion_warning_mails(num_days) - FactoryBot.create(:user, deletion_date: Date.current + num_days.days) + FactoryBot.create(:confirmed_user, deletion_date: Date.current + num_days.days) expect do UserCleaner.new.send_additional_warning_mails @@ -181,7 +228,7 @@ def test_enqueues_additional_deletion_warning_mails(num_days) end it "does not enqueue an additional deletion warning mail for 40 days" do - FactoryBot.create(:user, deletion_date: Date.current + 40.days) + FactoryBot.create(:confirmed_user, deletion_date: Date.current + 40.days) expect do UserCleaner.new.send_additional_warning_mails @@ -201,7 +248,7 @@ def test_enqueues_additional_deletion_warning_mails(num_days) context "when a user is finally deleted" do it "enqueues a deletion mail" do - FactoryBot.create(:user, deletion_date: Date.current - 1.day) + FactoryBot.create(:confirmed_user, deletion_date: Date.current - 1.day) expect do UserCleaner.new.delete_users_according_to_deletion_date! @@ -211,11 +258,11 @@ def test_enqueues_additional_deletion_warning_mails(num_days) end describe("#pending_deletion_mail") do - let(:user_de) { FactoryBot.create(:user, locale: "de") } - let(:user_en) { FactoryBot.create(:user, locale: "en") } + let(:user_de) { FactoryBot.create(:confirmed_user, locale: "de") } + let(:user_en) { FactoryBot.create(:confirmed_user, locale: "en") } def test_subject_line(num_days) - user = FactoryBot.create(:user) + user = FactoryBot.create(:confirmed_user) mailer = UserCleanerMailer.with(user: user).pending_deletion_email(num_days) expect(mailer.subject).to include(num_days.to_s) end @@ -252,8 +299,8 @@ def test_subject_line(num_days) end describe("#deletion_mail") do - let(:user_de) { FactoryBot.create(:user, locale: "de") } - let(:user_en) { FactoryBot.create(:user, locale: "en") } + let(:user_de) { FactoryBot.create(:confirmed_user, locale: "de") } + let(:user_en) { FactoryBot.create(:confirmed_user, locale: "en") } it "has mail subject localized to the user's locale" do mailer = UserCleanerMailer.with(user: user_de).deletion_email diff --git a/spec/models/voucher_spec.rb b/spec/models/voucher_spec.rb index 43acd86d0..f571ef858 100644 --- a/spec/models/voucher_spec.rb +++ b/spec/models/voucher_spec.rb @@ -20,38 +20,22 @@ end describe "#add_expiration_datetime" do - let(:voucher) { build(:voucher, role: role, created_at: Time.zone.now) } - - context "when the voucher is for a speaker" do - let(:role) { :speaker } - - it "sets the expiration date to SPEAKER_EXPIRATION_DAYS from created_at" do - voucher.save - expect(voucher.expires_at).to( - eq(voucher.created_at + Voucher::SPEAKER_EXPIRATION_DAYS.days) - ) + def expiration_days(role) + case role + when :speaker + Voucher::SPEAKER_EXPIRATION_DAYS + when :tutor + Voucher::TUTOR_EXPIRATION_DAYS + else + Voucher::DEFAULT_EXPIRATION_DAYS end end - context "when the voucher is for a tutor" do - let(:role) { :tutor } - - it "sets the expiration date to TUTOR_EXPIRATION_DAYS from created_at" do + it "sets the expiration date correctly based on the role" do + Voucher::ROLE_HASH.each_key do |role| + voucher = build(:voucher, lecture: lecture, role: role) voucher.save - expect(voucher.expires_at).to( - eq(voucher.created_at + Voucher::TUTOR_EXPIRATION_DAYS.days) - ) - end - end - - context "when the voucher is for another role" do - let(:role) { :teacher } - - it "sets the expiration date to DEFAULT_EXPIRATION_DAYS from created_at" do - voucher.save - expect(voucher.expires_at).to( - eq(voucher.created_at + Voucher::DEFAULT_EXPIRATION_DAYS.days) - ) + expect(voucher.expires_at).to eq(voucher.created_at + expiration_days(role).days) end end end @@ -70,10 +54,11 @@ end describe "#ensure_speaker_vouchers_only_for_seminars" do - context "whe the lecture is a seminar" do + context "when the lecture is a seminar" do let(:voucher) { build(:voucher, :speaker, lecture: seminar) } it "does not add an error" do + expect(voucher).to be_valid expect(voucher.save).to be_truthy expect(voucher.errors[:role]).to be_empty end @@ -95,21 +80,25 @@ describe "scopes" do describe ".active" do - let!(:active_voucher) { FactoryBot.create(:voucher) } - let!(:expired_voucher) { FactoryBot.create(:voucher, :expired) } - let!(:invalidated_voucher) { FactoryBot.create(:voucher, :invalidated) } - it "includes vouchers that are not expired and not invalidated" do + active_voucher = FactoryBot.create(:voucher) expect(Voucher.active).to include(active_voucher) end it "excludes vouchers that are expired" do + expired_voucher = FactoryBot.create(:voucher, :expired) expect(Voucher.active).not_to include(expired_voucher) end it "excludes vouchers that are invalidated" do + invalidated_voucher = FactoryBot.create(:voucher, :invalidated) expect(Voucher.active).not_to include(invalidated_voucher) end + + it "excludes vouchers that are both expired and invalidated" do + voucher = FactoryBot.create(:voucher, :expired, :invalidated) + expect(Voucher.active).not_to include(voucher) + end end end