From 3980559ba98d4af2a98fe5959a967e8b43b19642 Mon Sep 17 00:00:00 2001 From: fosterfarrell9 <28628554+fosterfarrell9@users.noreply.github.com> Date: Sun, 28 Jul 2024 15:22:06 +0200 Subject: [PATCH 001/191] Initialize voucher model and add some unit tests --- app/models/lecture.rb | 4 + app/models/voucher.rb | 39 +++++++++ config/locales/de.yml | 5 ++ config/locales/en.yml | 5 ++ db/migrate/20240728123817_create_vouchers.rb | 12 +++ db/schema.rb | 14 +++- spec/factories/vouchers.rb | 24 ++++++ spec/models/voucher_spec.rb | 83 ++++++++++++++++++++ 8 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 app/models/voucher.rb create mode 100644 db/migrate/20240728123817_create_vouchers.rb create mode 100644 spec/factories/vouchers.rb create mode 100644 spec/models/voucher_spec.rb diff --git a/app/models/lecture.rb b/app/models/lecture.rb index 7fd197471..81eb0b960 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 + # a lecture has many structure_ids, referring to the ids of structures # in the erdbeere database serialize :structure_ids, type: Array, coder: YAML diff --git a/app/models/voucher.rb b/app/models/voucher.rb new file mode 100644 index 000000000..d7b1ea8eb --- /dev/null +++ b/app/models/voucher.rb @@ -0,0 +1,39 @@ +class Voucher < ApplicationRecord + enum sort: { tutor: 0, editor: 1, teacher: 2 } + + belongs_to :lecture + before_create :generate_secure_hash + before_create :add_expiration_datetime + validates :sort, presence: true + validate :ensure_no_other_active_voucher + + scope :active, -> { where("expires_at > ?", Time.zone.now) } + + self.implicit_order_column = "created_at" + + def expired? + expires_at <= Time.now + end + + def active? + expires_at > Time.now + end + + private + + def generate_secure_hash + self.secure_hash = SecureRandom.hex(16) + end + + def add_expiration_datetime + self.expires_at = created_at + 90.days + end + + def ensure_no_other_active_voucher + return unless lecture + return unless lecture.vouchers.where(sort: sort).any?(&:active?) + + errors.add(:sort, + I18n.t("activerecord.errors.models.voucher.attributes.sort.only_one_active")) + end +end diff --git a/config/locales/de.yml b/config/locales/de.yml index 23ec38b1d..c1fb42d4c 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -3893,6 +3893,11 @@ de: out_of_range: > Das abgegebene Votum ist leider außerhalb des zulässingen Bereiches. + voucher: + attributes: + sort: + only_one_active: > + Für diese Vorlesung kann es nur einen aktiven Voucher dieses Typs geben. watchlist_entry: attributes: medium_id: diff --git a/config/locales/en.yml b/config/locales/en.yml index 6eb83a3c3..21c2274d5 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -3684,6 +3684,11 @@ en: value: out_of_range: > Your vote is outside of the allowed range. + voucher: + attributes: + sort: + only_one_active: > + There can be only one active voucher of this sort for the lecture. watchlist_entry: attributes: medium_id: diff --git a/db/migrate/20240728123817_create_vouchers.rb b/db/migrate/20240728123817_create_vouchers.rb new file mode 100644 index 000000000..3b0e6e8cd --- /dev/null +++ b/db/migrate/20240728123817_create_vouchers.rb @@ -0,0 +1,12 @@ +class CreateVouchers < ActiveRecord::Migration[7.1] + def change + create_table :vouchers, id: :uuid, default: "gen_random_uuid()" do |t| + t.integer :sort, null: false + t.references :lecture, null: false, foreign_key: true + t.string :secure_hash, null: false + 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 6cd1b3f61..41022ff23 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_04_22_200000) do +ActiveRecord::Schema[7.1].define(version: 2024_07_28_123817) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -902,6 +902,17 @@ t.index ["voter_type", "voter_id"], name: "index_votes_on_voter_type_and_voter_id" end + create_table "vouchers", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.integer "sort", null: false + t.bigint "lecture_id", null: false + t.string "secure_hash", null: false + t.datetime "expires_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["lecture_id"], name: "index_vouchers_on_lecture_id" + t.index ["secure_hash"], name: "index_vouchers_on_secure_hash", unique: true + end + create_table "vtt_containers", force: :cascade do |t| t.text "table_of_contents_data" t.text "references_data" @@ -973,6 +984,7 @@ add_foreign_key "user_favorite_lecture_joins", "lectures" add_foreign_key "user_favorite_lecture_joins", "users" add_foreign_key "user_submission_joins", "users" + add_foreign_key "vouchers", "lectures" add_foreign_key "watchlist_entries", "media" add_foreign_key "watchlist_entries", "watchlists" add_foreign_key "watchlists", "users" diff --git a/spec/factories/vouchers.rb b/spec/factories/vouchers.rb new file mode 100644 index 000000000..0b5524c4f --- /dev/null +++ b/spec/factories/vouchers.rb @@ -0,0 +1,24 @@ +FactoryBot.define do + factory :voucher do + sort { :tutor } + association :lecture + + trait :tutor do + sort { :tutor } + end + + trait :student do + sort { :student } + end + + trait :teacher do + sort { :teacher } + end + + trait :expired do + after(:create) do |voucher| + voucher.update(expires_at: 1.day.ago) + end + end + end +end diff --git a/spec/models/voucher_spec.rb b/spec/models/voucher_spec.rb new file mode 100644 index 000000000..0f6765a14 --- /dev/null +++ b/spec/models/voucher_spec.rb @@ -0,0 +1,83 @@ +require "rails_helper" + +RSpec.describe(Voucher, type: :model) do + let(:lecture) { FactoryBot.create(:lecture) } + + describe "callbacks" do + it "generates a secure hash before creating" do + voucher = FactoryBot.build(:voucher) + expect(voucher.secure_hash).to be_nil + voucher.save + expect(voucher.secure_hash).not_to be_nil + end + + it "adds an expiration datetime before creating" do + voucher = FactoryBot.build(:voucher) + expect(voucher.expires_at).to be_nil + voucher.save + expect(voucher.expires_at).not_to be_nil + end + end + + describe "scopes" do + describe ".active" do + it "returns active vouchers" do + active_voucher = FactoryBot.create(:voucher) + expired_voucher = FactoryBot.create(:voucher, :expired) + + expect(expired_voucher.expired?).to be(true) + expect(Voucher.active.count).to eq(1) + expect(Voucher.active).to include(active_voucher) + expect(Voucher.active).not_to include(expired_voucher) + end + end + end + + describe "instance methods" do + describe "#expired?" do + it "returns true if the voucher has expired" do + voucher = FactoryBot.build(:voucher, expires_at: Time.now - 1.day) + expect(voucher.expired?).to be(true) + end + + it "returns false if the voucher has not expired" do + voucher = FactoryBot.build(:voucher, expires_at: Time.now + 1.day) + expect(voucher.expired?).to be(false) + end + end + + describe "#active?" do + it "returns true if the voucher is active" do + voucher = FactoryBot.build(:voucher, expires_at: Time.now + 1.day) + expect(voucher.active?).to be(true) + end + + it "returns false if the voucher is not active" do + voucher = FactoryBot.build(:voucher, expires_at: Time.now - 1.day) + expect(voucher.active?).to be(false) + end + end + end + + describe "custom validations" do + describe "#ensure_no_other_active_voucher" do + it "adds an error if there is another active voucher with the same " \ + "sort for the lecture" do + FactoryBot.create(:voucher, lecture: lecture, sort: :tutor) + voucher = FactoryBot.build(:voucher, lecture: lecture, sort: :tutor) + + expect(voucher).not_to be_valid + expect(voucher.errors[:sort]).to include(I18n.t("activerecord.errors.models.voucher." \ + "attributes.sort.only_one_active")) + end + + it "does not add an error if there is no other active voucher with the " \ + "same sort for the lecture" do + voucher = FactoryBot.build(:voucher, lecture: lecture, sort: :tutor) + + expect(voucher).to be_valid + expect(voucher.errors[:sort]).to be_empty + end + end + end +end From f9876e46a8f69196d81b3d189d8d5bb3deca386d Mon Sep 17 00:00:00 2001 From: fosterfarrell9 <28628554+fosterfarrell9@users.noreply.github.com> Date: Sun, 28 Jul 2024 15:48:51 +0200 Subject: [PATCH 002/191] Make ensure_no_other_active_voucher validation into a callback --- app/models/voucher.rb | 2 +- spec/models/voucher_spec.rb | 34 ++++++++++------------------------ 2 files changed, 11 insertions(+), 25 deletions(-) diff --git a/app/models/voucher.rb b/app/models/voucher.rb index d7b1ea8eb..34af6fd19 100644 --- a/app/models/voucher.rb +++ b/app/models/voucher.rb @@ -4,8 +4,8 @@ class Voucher < ApplicationRecord belongs_to :lecture before_create :generate_secure_hash before_create :add_expiration_datetime + before_create :ensure_no_other_active_voucher validates :sort, presence: true - validate :ensure_no_other_active_voucher scope :active, -> { where("expires_at > ?", Time.zone.now) } diff --git a/spec/models/voucher_spec.rb b/spec/models/voucher_spec.rb index 0f6765a14..13e5e62e6 100644 --- a/spec/models/voucher_spec.rb +++ b/spec/models/voucher_spec.rb @@ -17,6 +17,16 @@ voucher.save expect(voucher.expires_at).not_to be_nil end + + it "rolls back if there is another active voucher with the same sort for the lecture" do + FactoryBot.create(:voucher, lecture: lecture, sort: :tutor) + voucher = FactoryBot.build(:voucher, lecture: lecture, sort: :tutor) + voucher.save + + expect(voucher).not_to be_valid + expect(voucher.errors[:sort]).to include(I18n.t("activerecord.errors.models.voucher." \ + "attributes.sort.only_one_active")) + end end describe "scopes" do @@ -25,8 +35,6 @@ active_voucher = FactoryBot.create(:voucher) expired_voucher = FactoryBot.create(:voucher, :expired) - expect(expired_voucher.expired?).to be(true) - expect(Voucher.active.count).to eq(1) expect(Voucher.active).to include(active_voucher) expect(Voucher.active).not_to include(expired_voucher) end @@ -58,26 +66,4 @@ end end end - - describe "custom validations" do - describe "#ensure_no_other_active_voucher" do - it "adds an error if there is another active voucher with the same " \ - "sort for the lecture" do - FactoryBot.create(:voucher, lecture: lecture, sort: :tutor) - voucher = FactoryBot.build(:voucher, lecture: lecture, sort: :tutor) - - expect(voucher).not_to be_valid - expect(voucher.errors[:sort]).to include(I18n.t("activerecord.errors.models.voucher." \ - "attributes.sort.only_one_active")) - end - - it "does not add an error if there is no other active voucher with the " \ - "same sort for the lecture" do - voucher = FactoryBot.build(:voucher, lecture: lecture, sort: :tutor) - - expect(voucher).to be_valid - expect(voucher.errors[:sort]).to be_empty - end - end - end end From 1e057fc7d25f6c2bace4b561c8d4a6d9be8277d4 Mon Sep 17 00:00:00 2001 From: fosterfarrell9 <28628554+fosterfarrell9@users.noreply.github.com> Date: Sun, 4 Aug 2024 13:02:52 +0200 Subject: [PATCH 003/191] Add throw :abort in order to halt execution --- app/models/voucher.rb | 1 + spec/models/voucher_spec.rb | 11 +++++------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/models/voucher.rb b/app/models/voucher.rb index 34af6fd19..c93aea35e 100644 --- a/app/models/voucher.rb +++ b/app/models/voucher.rb @@ -35,5 +35,6 @@ def ensure_no_other_active_voucher errors.add(:sort, I18n.t("activerecord.errors.models.voucher.attributes.sort.only_one_active")) + throw(:abort) end end diff --git a/spec/models/voucher_spec.rb b/spec/models/voucher_spec.rb index 13e5e62e6..03d228a8a 100644 --- a/spec/models/voucher_spec.rb +++ b/spec/models/voucher_spec.rb @@ -19,13 +19,12 @@ end it "rolls back if there is another active voucher with the same sort for the lecture" do - FactoryBot.create(:voucher, lecture: lecture, sort: :tutor) - voucher = FactoryBot.build(:voucher, lecture: lecture, sort: :tutor) - voucher.save + FactoryBot.create(:voucher, :tutor, lecture: lecture) + new_voucher = build(:voucher, :tutor, lecture: lecture) - expect(voucher).not_to be_valid - expect(voucher.errors[:sort]).to include(I18n.t("activerecord.errors.models.voucher." \ - "attributes.sort.only_one_active")) + expect(new_voucher.save).to be_falsey + expect(new_voucher.errors[:sort]).to include(I18n.t("activerecord.errors.models.voucher." \ + "attributes.sort.only_one_active")) end end From 681fe9423662d286349ac1c09ea5786f3c88c8e2 Mon Sep 17 00:00:00 2001 From: fosterfarrell9 <28628554+fosterfarrell9@users.noreply.github.com> Date: Sun, 4 Aug 2024 13:13:10 +0200 Subject: [PATCH 004/191] Replace Time.now by Time.zone.now --- app/models/voucher.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/voucher.rb b/app/models/voucher.rb index c93aea35e..d51e828d2 100644 --- a/app/models/voucher.rb +++ b/app/models/voucher.rb @@ -12,11 +12,11 @@ class Voucher < ApplicationRecord self.implicit_order_column = "created_at" def expired? - expires_at <= Time.now + expires_at <= Time.zone.now end def active? - expires_at > Time.now + expires_at > Time.zone.now end private From f514ffbe68f5a8cdad84e76e015646394a63cad3 Mon Sep 17 00:00:00 2001 From: fosterfarrell9 <28628554+fosterfarrell9@users.noreply.github.com> Date: Sun, 4 Aug 2024 15:47:59 +0200 Subject: [PATCH 005/191] Set up basic functionality for display of vouchers for tutors --- app/assets/stylesheets/lectures.scss | 5 ++++ app/helpers/lectures_helper.rb | 27 +++++++++++++++++++ app/models/lecture.rb | 10 +++++++ app/views/lectures/edit/_form.html.erb | 10 ++++--- .../lectures/edit/_tutorial_voucher.html.erb | 27 +++++++++++++++++++ app/views/lectures/edit/_vouchers.html.erb | 12 +++++++++ config/locales/de.yml | 7 +++++ config/locales/en.yml | 7 +++++ spec/models/lecture_spec.rb | 25 +++++++++++++++++ 9 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 app/views/lectures/edit/_tutorial_voucher.html.erb create mode 100644 app/views/lectures/edit/_vouchers.html.erb diff --git a/app/assets/stylesheets/lectures.scss b/app/assets/stylesheets/lectures.scss index 2ee04b58b..bcc766892 100644 --- a/app/assets/stylesheets/lectures.scss +++ b/app/assets/stylesheets/lectures.scss @@ -118,6 +118,11 @@ h3.lecture-pane-header { font-size: 1.3em; } +h4.lecture-pane-subheader { + color: #838383; + font-size: 1.1em; +} + #announcements-list { max-height: 17em; overflow-x: hidden; diff --git a/app/helpers/lectures_helper.rb b/app/helpers/lectures_helper.rb index b7d7f739e..58f90e174 100644 --- a/app/helpers/lectures_helper.rb +++ b/app/helpers/lectures_helper.rb @@ -129,4 +129,31 @@ def lecture_view_icon(lecture) tag.i(class: "fas fa-eye") end end + + # def active_voucher_details(lecture, sort) + # voucher = lecture.active_voucher_of_sort(sort) + # if voucher + # "Expires at: #{voucher.expires_at}, Secure Hash: #{voucher.secure_hash}" + # else + # "No active #{sort} voucher" + # end + # end + + def active_voucher_details(lecture, sort) + voucher = lecture.active_voucher_of_sort(sort) + if voucher + content_tag(:div) do + concat(content_tag(:span, "Secure Hash: #{voucher.secure_hash}")) + concat(content_tag(:i, "", class: "clipboardpopup far fa-copy clickable text-secondary clipboard-btn ms-3 clipboard-button", + data: { clipboard_text: voucher.secure_hash, bs_toggle: "tooltip", id: "42" }, + title: t("buttons.copy_to_clipboard"))) do + content_tag(:span, t("basics.code_copied_to_clipboard"), + class: "clipboardpopuptext token-clipboard-popup", data: { id: "42" }) + end + concat(content_tag(:span, "Expires at: #{voucher.expires_at}")) + end + else + content_tag(:p, "No active #{sort} voucher") + end + end end diff --git a/app/models/lecture.rb b/app/models/lecture.rb index 81eb0b960..cc8ebc464 100644 --- a/app/models/lecture.rb +++ b/app/models/lecture.rb @@ -848,6 +848,16 @@ def valid_annotations_status? [0, 1].include?(annotations_status) end + def active_voucher_of_sort?(sort) + vouchers.where(sort: sort).any?(&:active?) + end + + def active_voucher_of_sort(sort) + return unless active_voucher_of_sort?(sort) + + vouchers.find_by(sort: sort) + end + private # used for after save callback diff --git a/app/views/lectures/edit/_form.html.erb b/app/views/lectures/edit/_form.html.erb index 5060e5b82..293b5f6c9 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 } %> - +