Skip to content

Commit

Permalink
Vouchers for user promotion - Part 1: Introduction of Vouchers (#670)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

* 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 <[email protected]>
Co-authored-by: Splines <[email protected]>
  • Loading branch information
3 people authored Sep 9, 2024
1 parent 78467de commit c00ae31
Show file tree
Hide file tree
Showing 35 changed files with 1,011 additions and 20 deletions.
30 changes: 30 additions & 0 deletions .config/.cypress.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
},
},
};
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
"factorybot",
"helpdesk",
"katex",
"Timecop",
"turbolinks"
]
}
3 changes: 2 additions & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 3 additions & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
11 changes: 11 additions & 0 deletions app/abilities/voucher_ability.rb
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions app/assets/javascripts/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
//= require bootstrap_popovers
//= require chapters
//= require clickers
//= require copy_and_paste_button
//= require courses
//= require erdbeere
//= require file_upload
Expand Down
32 changes: 32 additions & 0 deletions app/assets/javascripts/copy_and_paste_button.js
Original file line number Diff line number Diff line change
@@ -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");
});
12 changes: 0 additions & 12 deletions app/assets/javascripts/submissions.coffee
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
$(document).on 'turbolinks:load', ->
clipboard = new Clipboard('.clipboard-btn')

$(document).on 'click', '#removeUserManuscript', ->
$('#userManuscriptMetadata').hide()
Expand All @@ -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
10 changes: 10 additions & 0 deletions app/assets/stylesheets/lectures.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion app/assets/stylesheets/submissions.scss
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
}

/* The actual popup */
.clipboardpopup .clipboardpopuptext {
.clipboardpopuptext {
visibility: hidden;
width: 200px;
background-color: #555;
Expand Down
3 changes: 3 additions & 0 deletions app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions app/controllers/cypress/timecop_controller.rb
Original file line number Diff line number Diff line change
@@ -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
83 changes: 83 additions & 0 deletions app/controllers/vouchers_controller.rb
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions app/models/lecture.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions app/models/voucher.rb
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit c00ae31

Please sign in to comment.