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 } %> - +