Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Vouchers for user promotion - Part 1: Introduction of Vouchers #670

Merged
merged 59 commits into from
Sep 9, 2024
Merged
Show file tree
Hide file tree
Changes from 56 commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
3980559
Initialize voucher model and add some unit tests
fosterfarrell9 Jul 28, 2024
f9876e4
Make ensure_no_other_active_voucher validation into a callback
fosterfarrell9 Jul 28, 2024
1e057fc
Add throw :abort in order to halt execution
fosterfarrell9 Aug 4, 2024
681fe94
Replace Time.now by Time.zone.now
fosterfarrell9 Aug 4, 2024
f514ffb
Set up basic functionality for display of vouchers for tutors
fosterfarrell9 Aug 4, 2024
eb1d17d
Create separate file for copy and paste button code and decaffeinate
fosterfarrell9 Aug 4, 2024
b594cc3
Set up destruction of tutor vouchers
fosterfarrell9 Aug 4, 2024
8f980b0
Merge branch 'dev' into feature/vouchers-user-promotion
fosterfarrell9 Aug 4, 2024
3b5425c
Rename view file as it containes embedded ruby
fosterfarrell9 Aug 4, 2024
aad2134
Add create action for vouchers and corresponding views
fosterfarrell9 Aug 5, 2024
e377f03
Adapt views and controller for adding and removing of vouchers of dif…
fosterfarrell9 Aug 6, 2024
9f4edc9
Put duplicate lines into a separate method
fosterfarrell9 Aug 6, 2024
74637b3
Set up redeeming of vouchers
fosterfarrell9 Aug 6, 2024
db61b04
fix typo
fosterfarrell9 Aug 8, 2024
71ea44b
remove obsolete methods
fosterfarrell9 Aug 8, 2024
d54a2ab
Avoid use of Time.now
fosterfarrell9 Aug 8, 2024
2a91c7c
Refactor active_vouhcer_of_sort method
fosterfarrell9 Aug 9, 2024
f63e204
remove unused expired? method
fosterfarrell9 Aug 9, 2024
3a30f97
Remove duplicate code
fosterfarrell9 Aug 9, 2024
5751c7b
remove unused variable
fosterfarrell9 Aug 9, 2024
4149f5e
Add controller spec for vouchers
fosterfarrell9 Aug 9, 2024
cab3981
Rewrite controller spec for vouchers
fosterfarrell9 Aug 11, 2024
5efa753
remove obsolete comment
fosterfarrell9 Aug 11, 2024
b533d81
Merge branch 'dev' into feature/vouchers-user-promotion
fosterfarrell9 Aug 14, 2024
93d54f9
Invalidate vouchers instead of destroying them
fosterfarrell9 Aug 16, 2024
c00f482
Add vouchers for seminar speakers
fosterfarrell9 Aug 18, 2024
a704e57
Rename sort attribute of vouchers to role
fosterfarrell9 Aug 19, 2024
11fb080
Add cypress data attributes
fosterfarrell9 Aug 20, 2024
215b46b
Add first cypress tests
fosterfarrell9 Aug 20, 2024
e33db99
Add more cypress tests
fosterfarrell9 Aug 21, 2024
7429387
Init future possibility to check clipboard content
Splines Aug 21, 2024
8e9d0f9
Merge branch 'dev' into feature/vouchers-user-promotion
fosterfarrell9 Aug 22, 2024
7289053
Remove unnecessary call of trait
fosterfarrell9 Aug 24, 2024
5a65708
Use NO_SEMINAR_ROLES constant
fosterfarrell9 Aug 24, 2024
68c5daa
Refactor JS for copy/paste button
Splines Sep 5, 2024
5b21d1e
Redesign voucher creation/deletion
Splines Sep 5, 2024
e85ff95
Add explanations for what a voucher is
Splines Sep 5, 2024
72f7441
Fix minor UI inconsistencies
Splines Sep 5, 2024
661632f
Revert downcasing
Splines Sep 5, 2024
8d0467c
Improve cypress tests
Splines Sep 5, 2024
0f9c8fb
Indent if condition in HTML
Splines Sep 5, 2024
cd8339f
Add TODO note to redeem controller method
Splines Sep 5, 2024
3199aa7
Remove unnecessary comment
Splines Sep 5, 2024
8e41916
Group role-related stuff together in model
Splines Sep 5, 2024
1c2a3cd
Improve voucher model specs
Splines Sep 5, 2024
3df001b
Update db/migrate/20240728123817_create_vouchers.rb
fosterfarrell9 Sep 5, 2024
fae9da5
Add missing speaker trait
fosterfarrell9 Sep 5, 2024
6bc7dbb
Use symbol for implicit order column
Splines Sep 5, 2024
02a4e10
Update timestamp for create vouchers migration
Splines Sep 6, 2024
cb5863d
Simplify ability handling via automatic resource loading
Splines Sep 6, 2024
298f483
Make whole copy button area clickable & simplify
Splines Sep 7, 2024
7a643e2
Init time traveling in Cypress tests & test expired vouchers in frontend
Splines Sep 8, 2024
647aa5f
Remove accidental `it.only` flag
Splines Sep 8, 2024
8042dda
Refactor Cypress voucher specs common assertions
Splines Sep 8, 2024
79e9efc
Merge branch 'dev' into feature/vouchers-user-promotion
Splines Sep 8, 2024
65359c0
Run `bundle install` again due to new version specifier
Splines Sep 8, 2024
52f6e9d
Rename spec method
Splines Sep 8, 2024
8a4b6c2
Improve cypress test description
Splines Sep 8, 2024
6046ac7
Merge branch 'dev' into feature/vouchers-user-promotion
Splines Sep 9, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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?
Splines marked this conversation as resolved.
Show resolved Hide resolved

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
fosterfarrell9 marked this conversation as resolved.
Show resolved Hide resolved
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]
fosterfarrell9 marked this conversation as resolved.
Show resolved Hide resolved
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
fosterfarrell9 marked this conversation as resolved.
Show resolved Hide resolved
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