From 0d39b9e6b4f06b0b33568a6d249c83fa43607f89 Mon Sep 17 00:00:00 2001 From: Splines <37160523+Splines@users.noreply.github.com> Date: Tue, 20 Aug 2024 17:02:27 +0200 Subject: [PATCH 01/31] Add `just` commands for docker logs (#681) * Add `just` commands for docker logs * Fix `logs` command comment --- .config/commands/docker.justfile | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.config/commands/docker.justfile b/.config/commands/docker.justfile index 15872ed4b..4106c2897 100644 --- a/.config/commands/docker.justfile +++ b/.config/commands/docker.justfile @@ -9,6 +9,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: From 6d09e9e6b98467477ccc1cb4cfc9805555c05527 Mon Sep 17 00:00:00 2001 From: fosterfarrell9 <28628554+fosterfarrell9@users.noreply.github.com> Date: Wed, 21 Aug 2024 19:44:12 +0200 Subject: [PATCH 02/31] Fix lecture edit page if active term is not set (#683) * Add missing case that Term.active is nil * Add unit tests for lecture#stale? * Make lecture#older_than? method private * Add cypress test for lecture edit page * Make better use of .then() Co-authored-by: Splines <37160523+Splines@users.noreply.github.com> * Add docstring * Remove obsolete line * Rename for consistency * Add missing punctuation to docstring --------- Co-authored-by: Splines <37160523+Splines@users.noreply.github.com> Co-authored-by: Splines --- app/models/lecture.rb | 16 ++++++---- app/views/lectures/edit/_form.html.erb | 1 + spec/cypress/e2e/lectures_spec.cy.js | 15 +++++++++ spec/factories/terms.rb | 8 +++++ spec/models/lecture_spec.rb | 44 ++++++++++++++++++++++++++ 5 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 spec/cypress/e2e/lectures_spec.cy.js diff --git a/app/models/lecture.rb b/app/models/lecture.rb index 7fd197471..973ac82d0 100644 --- a/app/models/lecture.rb +++ b/app/models/lecture.rb @@ -830,12 +830,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 @@ -947,4 +944,11 @@ 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 end diff --git a/app/views/lectures/edit/_form.html.erb b/app/views/lectures/edit/_form.html.erb index 5060e5b82..00e2608c3 100644 --- a/app/views/lectures/edit/_form.html.erb +++ b/app/views/lectures/edit/_form.html.erb @@ -15,6 +15,7 @@ id="lecture-nav-content" type="button" role="tab" href="#content" data-bs-toggle="pill" data-bs-target="#lecture-pane-content" + data-cy="content-tab-btn" aria-controls="lecture-pane-content" aria-selected="true"> <%= t('content') %> diff --git a/spec/cypress/e2e/lectures_spec.cy.js b/spec/cypress/e2e/lectures_spec.cy.js new file mode 100644 index 000000000..96d3994ee --- /dev/null +++ b/spec/cypress/e2e/lectures_spec.cy.js @@ -0,0 +1,15 @@ +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", + { teacher_id: teacher.id }).as("lecture"); + }); + + cy.then(() => { + cy.visit(`/lectures/${this.lecture.id}/edit`); + cy.getBySelector("content-tab-btn").should("be.visible"); + }); + }); +}); diff --git a/spec/factories/terms.rb b/spec/factories/terms.rb index 9a1c6e949..3ecd347c3 100644 --- a/spec/factories/terms.rb +++ b/spec/factories/terms.rb @@ -8,5 +8,13 @@ trait :summer do season { "SS" } end + + trait :winter do + season { "WS" } + end + + trait :active do + active { true } + end end end diff --git a/spec/models/lecture_spec.rb b/spec/models/lecture_spec.rb index d2ba05682..59966f827 100644 --- a/spec/models/lecture_spec.rb +++ b/spec/models/lecture_spec.rb @@ -86,6 +86,50 @@ end end + describe "#stale?" do + context "when there is no active term" do + it "returns false" do + lecture = FactoryBot.build(:lecture) + expect(lecture.stale?).to be(false) + end + end + + context "when there is an active term" do + let(:year) { 2024 } + + before(:each) do + FactoryBot.create(:term, :summer, :active, year: year) + end + + context "and there is no term associated with the lecture" do + it "returns true" do + lecture = FactoryBot.build(:lecture, :term_independent) + expect(lecture.stale?).to be(true) + end + end + + context "and the lecture term begin date is before the active term" \ + "begin date minus 1 year" do + let(:lecture_term) { FactoryBot.build(:term, :summer, year: year - 1) } + + it "returns true" do + lecture = FactoryBot.build(:lecture, term: lecture_term) + expect(lecture.stale?).to be(true) + end + end + + context "when the lecture term begin date is not older than the" \ + "active term begin date minus 1 year" do + let(:lecture_term) { FactoryBot.build(:term, :winter, year: year - 1) } + + it "returns false" do + lecture = FactoryBot.build(:lecture, term: lecture_term) + expect(lecture.stale?).to be(false) + end + end + end + end + # Test methods -- NEEDS TO BE REFACTORED # describe '#tags' do From dd0d68bd760955e73870b439f2fe786f1b645d50 Mon Sep 17 00:00:00 2001 From: Splines <37160523+Splines@users.noreply.github.com> Date: Thu, 22 Aug 2024 23:28:02 +0200 Subject: [PATCH 03/31] Clean up Gemfile & pin all gems (#680) * Pin versions for GitHub repos * Replace >= with ~> and current version * Group gems according to name & clean up comments * Remove SQLite dependency We've been using PostgreSQL instead for a long time. * Pin all dependencies to allow only automatic minor updates --- .gitignore | 3 - Gemfile | 202 ++++++++++++++-------------------- Gemfile.lock | 192 +++++++++++++++----------------- config/database.yml | 8 -- docker/development/Dockerfile | 2 +- docker/test/Dockerfile | 2 +- 6 files changed, 168 insertions(+), 241 deletions(-) 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/Gemfile b/Gemfile index 88a53f4e6..ae1dffb97 100644 --- a/Gemfile +++ b/Gemfile @@ -1,144 +1,102 @@ 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 "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" +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" -# 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 "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 "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" # support for Capybara system testing and selenium driver, '~> 4.10.0' + gem "simplecov", "~> 0.22", require: false + 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..be6716650 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: @@ -133,6 +106,8 @@ GEM activemodel (= 7.1.3.4) activesupport (= 7.1.3.4) timeout (>= 0.4.0) + activerecord-import (1.7.0) + activerecord (>= 4.2) activerecord-nulldb-adapter (1.0.1) activerecord (>= 5.2.0, < 7.2) activestorage (7.1.3.4) @@ -620,7 +595,10 @@ 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) @@ -630,6 +608,9 @@ GEM 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) @@ -681,90 +662,89 @@ 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 - js-routes (= 1.4.9) - kaminari - kaminari-i18n - kramdown-parser-gfm - launchy + 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) + 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) + rubyzip (~> 2.3) + sass-rails (~> 6.0) 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 + 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) + trix-rails (~> 2.4) + turbolinks (~> 5.2) + web-console (~> 4.2) + webdrivers (~> 5.3) + webpacker (~> 5.4) RUBY VERSION ruby 3.1.4p223 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/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 From d48e369eda2f1faed6f71945d1652151a1426672 Mon Sep 17 00:00:00 2001 From: Splines <37160523+Splines@users.noreply.github.com> Date: Fri, 23 Aug 2024 12:37:05 +0200 Subject: [PATCH 04/31] Remove unnecessary `with_teacher_by_id` trait (#687) --- spec/cypress/e2e/annotations_overview_spec.cy.js | 6 +++--- spec/cypress/e2e/lectures_spec.cy.js | 2 +- spec/factories/lectures.rb | 7 ------- 3 files changed, 4 insertions(+), 11 deletions(-) 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/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/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] From 78467deb414b0dc66fc0350399bfa71a48288c5e Mon Sep 17 00:00:00 2001 From: Splines <37160523+Splines@users.noreply.github.com> Date: Sun, 8 Sep 2024 22:31:27 +0200 Subject: [PATCH 05/31] Explicitly set locale to German in course specs (#692) --- spec/models/course_spec.rb | 1 + 1 file changed, 1 insertion(+) 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], From c00ae31a0cb438e43eae85ea2911bb8b4af7f928 Mon Sep 17 00:00:00 2001 From: fosterfarrell9 <28628554+fosterfarrell9@users.noreply.github.com> Date: Mon, 9 Sep 2024 23:25:48 +0200 Subject: [PATCH 06/31] Vouchers for user promotion - Part 1: Introduction of Vouchers (#670) * Initialize voucher model and add some unit tests * Make ensure_no_other_active_voucher validation into a callback * Add throw :abort in order to halt execution * Replace Time.now by Time.zone.now * Set up basic functionality for display of vouchers for tutors * Create separate file for copy and paste button code and decaffeinate * Set up destruction of tutor vouchers * Rename view file as it containes embedded ruby * Add create action for vouchers and corresponding views * Adapt views and controller for adding and removing of vouchers of different type * Put duplicate lines into a separate method * Set up redeeming of vouchers * fix typo * remove obsolete methods * Avoid use of Time.now * Refactor active_vouhcer_of_sort method * remove unused expired? method * Remove duplicate code * remove unused variable * Add controller spec for vouchers * Rewrite controller spec for vouchers * remove obsolete comment * Invalidate vouchers instead of destroying them * Add vouchers for seminar speakers * Rename sort attribute of vouchers to role * Add cypress data attributes * Add first cypress tests * Add more cypress tests * Init future possibility to check clipboard content * Remove unnecessary call of trait * Use NO_SEMINAR_ROLES constant * Refactor JS for copy/paste button * Redesign voucher creation/deletion * Add explanations for what a voucher is * Fix minor UI inconsistencies * Revert downcasing Since in German, we cannot do that... * Improve cypress tests e.g. actually test format of UUID version 4 string * Indent if condition in HTML * Add TODO note to redeem controller method * Remove unnecessary comment * Group role-related stuff together in model * Improve voucher model specs * Update db/migrate/20240728123817_create_vouchers.rb Co-authored-by: Splines <37160523+Splines@users.noreply.github.com> * Add missing speaker trait * Use symbol for implicit order column * Update timestamp for create vouchers migration * Simplify ability handling via automatic resource loading See the docs: https://github.com/CanCanCommunity/cancancan/blob/develop/docs/controller_helpers.md#authorize_resource-load_resource-load_and_authorize_resource * Make whole copy button area clickable & simplify * Init time traveling in Cypress tests & test expired vouchers in frontend * Remove accidental `it.only` flag * Refactor Cypress voucher specs common assertions * Run `bundle install` again due to new version specifier (see also the merge comment before this commit) * Rename spec method * Improve cypress test description --------- Co-authored-by: Splines Co-authored-by: Splines <37160523+Splines@users.noreply.github.com> --- .config/.cypress.js | 30 ++++ .vscode/settings.json | 1 + Gemfile | 3 +- Gemfile.lock | 4 +- app/abilities/voucher_ability.rb | 11 ++ app/assets/javascripts/application.js | 1 + .../javascripts/copy_and_paste_button.js | 32 ++++ app/assets/javascripts/submissions.coffee | 12 -- app/assets/stylesheets/lectures.scss | 10 ++ app/assets/stylesheets/submissions.scss | 2 +- app/controllers/application_controller.rb | 3 + app/controllers/cypress/timecop_controller.rb | 25 +++ app/controllers/vouchers_controller.rb | 83 +++++++++ app/models/lecture.rb | 8 + app/models/voucher.rb | 66 +++++++ app/views/lectures/edit/_form.html.erb | 10 +- app/views/lectures/edit/_vouchers.html.erb | 20 +++ app/views/profile/_redeem_voucher.html.erb | 9 + app/views/profile/edit.html.erb | 21 +++ app/views/vouchers/_voucher.html.erb | 70 ++++++++ app/views/vouchers/create.js.erb | 2 + app/views/vouchers/error.js.erb | 1 + app/views/vouchers/invalidate.js.erb | 2 + config/locales/de.yml | 27 +++ config/locales/en.yml | 25 +++ config/routes.rb | 12 ++ db/migrate/20240906200000_create_vouchers.rb | 13 ++ db/schema.rb | 15 +- spec/controllers/vouchers_controller_spec.rb | 58 ++++++ spec/cypress/e2e/vouchers_spec.cy.js | 167 ++++++++++++++++++ spec/cypress/support/commands.js | 56 ++++++ spec/cypress/support/timecop.js | 59 +++++++ spec/factories/vouchers.rb | 32 ++++ spec/models/lecture_spec.rb | 25 +++ spec/models/voucher_spec.rb | 116 ++++++++++++ 35 files changed, 1011 insertions(+), 20 deletions(-) create mode 100644 app/abilities/voucher_ability.rb create mode 100644 app/assets/javascripts/copy_and_paste_button.js create mode 100644 app/controllers/cypress/timecop_controller.rb create mode 100644 app/controllers/vouchers_controller.rb create mode 100644 app/models/voucher.rb create mode 100644 app/views/lectures/edit/_vouchers.html.erb create mode 100644 app/views/profile/_redeem_voucher.html.erb create mode 100644 app/views/vouchers/_voucher.html.erb create mode 100644 app/views/vouchers/create.js.erb create mode 100644 app/views/vouchers/error.js.erb create mode 100644 app/views/vouchers/invalidate.js.erb create mode 100644 db/migrate/20240906200000_create_vouchers.rb create mode 100644 spec/controllers/vouchers_controller_spec.rb create mode 100644 spec/cypress/e2e/vouchers_spec.cy.js create mode 100644 spec/cypress/support/timecop.js create mode 100644 spec/factories/vouchers.rb create mode 100644 spec/models/voucher_spec.rb 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/.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/Gemfile b/Gemfile index ae1dffb97..88ba106dc 100644 --- a/Gemfile +++ b/Gemfile @@ -87,8 +87,9 @@ group :test do gem "database_cleaner-active_record", "~> 2.2" # clean up database between tests gem "faker", "~> 3.4" gem "launchy", "~> 3.0" - gem "selenium-webdriver" # support for Capybara system testing and selenium driver, '~> 4.10.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 diff --git a/Gemfile.lock b/Gemfile.lock index be6716650..337eaceaa 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -620,6 +620,7 @@ GEM thor (1.3.1) tilt (2.4.0) timeago_js (3.0.2.2) + timecop (0.9.10) timeout (0.4.1) tins (1.33.0) bigdecimal @@ -725,7 +726,7 @@ DEPENDENCIES rubocop-rails (~> 2.24) rubyzip (~> 2.3) sass-rails (~> 6.0) - selenium-webdriver + selenium-webdriver (~> 4.10.0) shrine (~> 3.6) sidekiq (~> 7.3) sidekiq-cron (~> 1.12) @@ -740,6 +741,7 @@ DEPENDENCIES terser (~> 1.2) thredded! thredded-markdown_katex! + timecop (~> 0.9.10) trix-rails (~> 2.4) turbolinks (~> 5.2) web-console (~> 4.2) diff --git a/app/abilities/voucher_ability.rb b/app/abilities/voucher_ability.rb new file mode 100644 index 000000000..173c693ae --- /dev/null +++ b/app/abilities/voucher_ability.rb @@ -0,0 +1,11 @@ +class VoucherAbility + include CanCan::Ability + + def initialize(user) + clear_aliased_actions + + can [:create, :invalidate], Voucher do |voucher| + user.can_update_personell?(voucher.lecture) + end + end +end diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index b6faa2615..facf7493c 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 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/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/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/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/application_controller.rb b/app/controllers/application_controller.rb index 0a5a60683..ea2e6c943 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -111,6 +111,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, 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 new file mode 100644 index 000000000..06a25b7e2 --- /dev/null +++ b/app/controllers/vouchers_controller.rb @@ -0,0 +1,83 @@ +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 redeem + # TODO: this will be dealt with in the corresponding 2nd PR + render js: "alert('Voucher redeemed!')" + 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 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 +end diff --git a/app/models/lecture.rb b/app/models/lecture.rb index 973ac82d0..28796e2fe 100644 --- a/app/models/lecture.rb +++ b/app/models/lecture.rb @@ -63,6 +63,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 @@ -841,6 +845,10 @@ def valid_annotations_status? [0, 1].include?(annotations_status) end + def active_voucher_of_role(role) + vouchers.where(role: role).active&.first + end + private # used for after save callback diff --git a/app/models/voucher.rb b/app/models/voucher.rb new file mode 100644 index 000000000..1517a8708 --- /dev/null +++ b/app/models/voucher.rb @@ -0,0 +1,66 @@ +class Voucher < ApplicationRecord + 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) + } + + self.implicit_order_column = :created_at + + def self.roles_for_lecture(lecture) + return ROLE_HASH.keys if lecture.seminar? + + ROLE_HASH.keys - [:speaker] + 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/lectures/edit/_form.html.erb b/app/views/lectures/edit/_form.html.erb index 00e2608c3..99d5c0871 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 } %> - +