diff --git a/Gemfile b/Gemfile index ff322343..acf60a8b 100644 --- a/Gemfile +++ b/Gemfile @@ -30,7 +30,7 @@ gem 'puma', '~> 6.6', '>= 6.6.1' gem 'pundit', '~> 2.5' gem 'rack-attack', '~> 6.7' gem 'rack-cors', '~> 3.0', require: 'rack/cors' -gem 'rails', '~> 7.2.2', '>= 7.2.2.1' +gem 'rails', '~> 7.2.3', '>= 7.2.3.2' gem 'rails-i18n', '~> 7.0', '>= 7.0.10' gem 'redis', '~> 5.4', '>= 5.4.1' gem 'roo', '~> 2.10', '>= 2.10.1' diff --git a/Gemfile.lock b/Gemfile.lock index a790f37a..57e2cc5b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,69 +1,71 @@ GEM remote: https://rubygems.org/ specs: - actioncable (7.2.2.1) - actionpack (= 7.2.2.1) - activesupport (= 7.2.2.1) + actioncable (7.2.3.2) + actionpack (= 7.2.3.2) + activesupport (= 7.2.3.2) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.2.2.1) - actionpack (= 7.2.2.1) - activejob (= 7.2.2.1) - activerecord (= 7.2.2.1) - activestorage (= 7.2.2.1) - activesupport (= 7.2.2.1) + actionmailbox (7.2.3.2) + actionpack (= 7.2.3.2) + activejob (= 7.2.3.2) + activerecord (= 7.2.3.2) + activestorage (= 7.2.3.2) + activesupport (= 7.2.3.2) mail (>= 2.8.0) - actionmailer (7.2.2.1) - actionpack (= 7.2.2.1) - actionview (= 7.2.2.1) - activejob (= 7.2.2.1) - activesupport (= 7.2.2.1) + actionmailer (7.2.3.2) + actionpack (= 7.2.3.2) + actionview (= 7.2.3.2) + activejob (= 7.2.3.2) + activesupport (= 7.2.3.2) mail (>= 2.8.0) rails-dom-testing (~> 2.2) - actionpack (7.2.2.1) - actionview (= 7.2.2.1) - activesupport (= 7.2.2.1) + actionpack (7.2.3.2) + actionview (= 7.2.3.2) + activesupport (= 7.2.3.2) + cgi nokogiri (>= 1.8.5) racc - rack (>= 2.2.4, < 3.2) + rack (>= 2.2.4, < 3.3) rack-session (>= 1.0.1) rack-test (>= 0.6.3) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) useragent (~> 0.16) - actiontext (7.2.2.1) - actionpack (= 7.2.2.1) - activerecord (= 7.2.2.1) - activestorage (= 7.2.2.1) - activesupport (= 7.2.2.1) + actiontext (7.2.3.2) + actionpack (= 7.2.3.2) + activerecord (= 7.2.3.2) + activestorage (= 7.2.3.2) + activesupport (= 7.2.3.2) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.2.2.1) - activesupport (= 7.2.2.1) + actionview (7.2.3.2) + activesupport (= 7.2.3.2) builder (~> 3.1) + cgi erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) active_model_otp (2.3.4) activemodel rotp (~> 6.3.0) - activejob (7.2.2.1) - activesupport (= 7.2.2.1) + activejob (7.2.3.2) + activesupport (= 7.2.3.2) globalid (>= 0.3.6) - activemodel (7.2.2.1) - activesupport (= 7.2.2.1) - activerecord (7.2.2.1) - activemodel (= 7.2.2.1) - activesupport (= 7.2.2.1) + activemodel (7.2.3.2) + activesupport (= 7.2.3.2) + activerecord (7.2.3.2) + activemodel (= 7.2.3.2) + activesupport (= 7.2.3.2) timeout (>= 0.4.0) - activestorage (7.2.2.1) - actionpack (= 7.2.2.1) - activejob (= 7.2.2.1) - activerecord (= 7.2.2.1) - activesupport (= 7.2.2.1) + activestorage (7.2.3.2) + actionpack (= 7.2.3.2) + activejob (= 7.2.3.2) + activerecord (= 7.2.3.2) + activesupport (= 7.2.3.2) marcel (~> 1.0) - activesupport (7.2.2.1) + activesupport (7.2.3.2) base64 benchmark (>= 0.3) bigdecimal @@ -72,7 +74,7 @@ GEM drb i18n (>= 1.6, < 2) logger (>= 1.4.2) - minitest (>= 5.1) + minitest (>= 5.1, < 6) securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) addressable (2.8.7) @@ -117,6 +119,8 @@ GEM fastimage case_transform (0.2) activesupport + cgi (0.5.2) + cgi (0.5.2-java) coderay (1.1.3) colorize (1.1.0) concurrent-ruby (1.3.5) @@ -347,20 +351,20 @@ GEM rack (>= 1.3) rackup (2.2.1) rack (>= 3) - rails (7.2.2.1) - actioncable (= 7.2.2.1) - actionmailbox (= 7.2.2.1) - actionmailer (= 7.2.2.1) - actionpack (= 7.2.2.1) - actiontext (= 7.2.2.1) - actionview (= 7.2.2.1) - activejob (= 7.2.2.1) - activemodel (= 7.2.2.1) - activerecord (= 7.2.2.1) - activestorage (= 7.2.2.1) - activesupport (= 7.2.2.1) + rails (7.2.3.2) + actioncable (= 7.2.3.2) + actionmailbox (= 7.2.3.2) + actionmailer (= 7.2.3.2) + actionpack (= 7.2.3.2) + actiontext (= 7.2.3.2) + actionview (= 7.2.3.2) + activejob (= 7.2.3.2) + activemodel (= 7.2.3.2) + activerecord (= 7.2.3.2) + activestorage (= 7.2.3.2) + activesupport (= 7.2.3.2) bundler (>= 1.15.0) - railties (= 7.2.2.1) + railties (= 7.2.3.2) rails-dom-testing (2.2.0) activesupport (>= 5.0.0) minitest @@ -371,13 +375,15 @@ GEM rails-i18n (7.0.10) i18n (>= 0.7, < 2) railties (>= 6.0.0, < 8) - railties (7.2.2.1) - actionpack (= 7.2.2.1) - activesupport (= 7.2.2.1) + railties (7.2.3.2) + actionpack (= 7.2.3.2) + activesupport (= 7.2.3.2) + cgi irb (~> 1.13) rackup (>= 1.0.0) rake (>= 12.2) thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.2.1) @@ -534,6 +540,7 @@ GEM timecop (0.9.10) timeliness (0.5.2) timeout (0.4.3) + tsort (0.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) tzinfo-data (1.2025.2) @@ -617,7 +624,7 @@ DEPENDENCIES rack-attack (~> 6.7) rack-cors (~> 3.0) rack-mini-profiler (~> 3.3, >= 3.3.1) - rails (~> 7.2.2, >= 7.2.2.1) + rails (~> 7.2.3, >= 7.2.3.2) rails-i18n (~> 7.0, >= 7.0.10) rb-readline (~> 0.5, >= 0.5.5) redis (~> 5.4, >= 5.4.1) diff --git a/app/models/alumni_contribution.rb b/app/models/alumni_contribution.rb new file mode 100644 index 00000000..8abce55c --- /dev/null +++ b/app/models/alumni_contribution.rb @@ -0,0 +1,12 @@ +class AlumniContribution < ApplicationRecord + has_paper_trail + + belongs_to :user + + validates :user, uniqueness: true + validates :sponsoring_amount, numericality: { greater_than_or_equal_to: 0, allow_nil: true } + validates :help_digtus, inclusion: [true, false] + validates :help_kring, inclusion: [true, false] + validates :help_vereniging, inclusion: [true, false] + validates :help_anders, length: { maximum: 1000, allow_nil: true } +end diff --git a/app/models/user.rb b/app/models/user.rb index 9761b2cd..2c22273a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -27,6 +27,7 @@ class User < ApplicationRecord # rubocop:disable Metrics/ClassLength has_many :mandates, class_name: 'Debit::Mandate', dependent: :delete_all has_many :transactions, class_name: 'Debit::Transaction', dependent: :delete_all has_many :group_mail_aliases, through: :active_groups, source: :mail_aliases + has_one :alumni_contribution, dependent: :destroy # See https://github.com/doorkeeper-gem/doorkeeper#active-record has_many :access_grants, class_name: 'Doorkeeper::AccessGrant', diff --git a/app/policies/alumni_contribution_policy.rb b/app/policies/alumni_contribution_policy.rb new file mode 100644 index 00000000..ddd4264e --- /dev/null +++ b/app/policies/alumni_contribution_policy.rb @@ -0,0 +1,35 @@ +class AlumniContributionPolicy < ApplicationPolicy + def index? + user_can_read? + end + + def show? + user_can_read? + end + + def create? + user_can_create? + end + + def update? + user_can_update? + end + + def destroy? + user_can_update? + end + + private + + def user_can_read? + user&.permission?(:read, record) || record.user == user + end + + def user_can_create? + user&.permission?(:create, record) + end + + def user_can_update? + user&.permission?(:update, record) || record.user == user + end +end diff --git a/app/resources/v1/alumni_contribution_resource.rb b/app/resources/v1/alumni_contribution_resource.rb new file mode 100644 index 00000000..d104d72a --- /dev/null +++ b/app/resources/v1/alumni_contribution_resource.rb @@ -0,0 +1,13 @@ +class V1::AlumniContributionResource < V1::ApplicationResource + attributes :sponsoring_amount, :help_digtus, :help_kring, :help_vereniging, :help_anders + + has_one :user, always_include_linkage_data: true + + def self.creatable_fields(_context) + %i[sponsoring_amount help_digtus help_kring help_vereniging help_anders] + end + + def self.updatable_fields(_context) + %i[sponsoring_amount help_digtus help_kring help_vereniging help_anders] + end +end diff --git a/config/brakeman.ignore b/config/brakeman.ignore index e6e759e6..ed06d2ce 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -1,6 +1,24 @@ { "ignored_warnings": [ + { + "warning_type": "Unmaintained Dependency", + "warning_code": 120, + "fingerprint": "d84924377155b41e094acae7404ec2e521629d86f97b0ff628e3d1b263f8101c", + "check_name": "EOLRails", + "message": "Support for Rails 7.2.3.2 ended on 2026-08-09", + "file": "Gemfile.lock", + "line": 354, + "link": "https://brakemanscanner.org/docs/warning_types/unmaintained_dependency/", + "code": null, + "render_path": null, + "location": null, + "user_input": null, + "confidence": "High", + "cwe_id": [ + 1104 + ], + "note": "ignored update comming soon" + } ], - "updated": "2016-06-03 22:39:14 +0200", - "brakeman_version": "3.3.1" + "brakeman_version": "7.1.1" } diff --git a/config/routes.rb b/config/routes.rb index 3dc11a66..a568df82 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -15,6 +15,7 @@ post :generate_alias end end + jsonapi_resources :alumni_contributions jsonapi_resources :articles jsonapi_resources :article_comments jsonapi_resources :board_room_presences diff --git a/db/migrate/20260828004330_create_alumni_contributions.rb b/db/migrate/20260828004330_create_alumni_contributions.rb new file mode 100644 index 00000000..974199a7 --- /dev/null +++ b/db/migrate/20260828004330_create_alumni_contributions.rb @@ -0,0 +1,14 @@ +class CreateAlumniContributions < ActiveRecord::Migration[7.0] + def change + create_table :alumni_contributions do |t| + t.references :user, null: false, foreign_key: true, index: { unique: true } + t.decimal :sponsoring_amount, precision: 10, scale: 2, default: 0.00 + t.boolean :help_digtus, default: false, null: false + t.boolean :help_kring, default: false, null: false + t.boolean :help_vereniging, default: false, null: false + t.text :help_anders + t.datetime :deleted_at + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 1812a52a..c4717d40 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: 2025_11_03_104056) do +ActiveRecord::Schema[7.2].define(version: 2026_08_28_004330) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -71,6 +71,19 @@ t.index ["form_id"], name: "index_activities_on_form_id", unique: true end + create_table "alumni_contributions", force: :cascade do |t| + t.bigint "user_id", null: false + t.decimal "sponsoring_amount", precision: 10, scale: 2, default: "0.0" + t.boolean "help_digtus", default: false, null: false + t.boolean "help_kring", default: false, null: false + t.boolean "help_vereniging", default: false, null: false + t.text "help_anders" + t.datetime "deleted_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id"], name: "index_alumni_contributions_on_user_id", unique: true + end + create_table "article_comments", id: :serial, force: :cascade do |t| t.text "content" t.integer "article_id" @@ -597,6 +610,7 @@ add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" + add_foreign_key "alumni_contributions", "users" add_foreign_key "article_comments", "articles" add_foreign_key "article_comments", "users", column: "author_id" add_foreign_key "articles", "groups" diff --git a/spec/factories/alumni_contributions.rb b/spec/factories/alumni_contributions.rb new file mode 100644 index 00000000..207de78a --- /dev/null +++ b/spec/factories/alumni_contributions.rb @@ -0,0 +1,10 @@ +FactoryBot.define do + factory :alumni_contribution do + user + sponsoring_amount { Faker::Number.decimal(l_digits: 3, r_digits: 2) } + help_digtus { [true, false].sample } + help_kring { [true, false].sample } + help_vereniging { [true, false].sample } + help_anders { [nil, Faker::Lorem.sentence].sample } + end +end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 1bd738db..6fa27bf8 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -37,6 +37,8 @@ transient do user_permission_list { [] } groups { [] } + with_alumni_contribution { false } + alumni_contribution_attributes { {} } end after :create do |user, evaluator| @@ -47,6 +49,10 @@ evaluator.groups.each do |group| FactoryBot.create(:membership, group:, user:) end + + if evaluator.with_alumni_contribution + FactoryBot.create(:alumni_contribution, user:, **evaluator.alumni_contribution_attributes) + end end end end diff --git a/spec/models/alumni_contribution_spec.rb b/spec/models/alumni_contribution_spec.rb new file mode 100644 index 00000000..7c3624b8 --- /dev/null +++ b/spec/models/alumni_contribution_spec.rb @@ -0,0 +1,104 @@ +require 'rails_helper' + +RSpec.describe AlumniContribution do + describe 'associations' do + it 'belongs to user' do + expect(described_class.reflect_on_association(:user)).to be_a(ActiveRecord::Reflection::BelongsToReflection) + end + end + + describe 'validations' do + subject(:alumni_contribution) { build(:alumni_contribution) } + + it { expect(alumni_contribution).to be_valid } + + describe 'user' do + it 'validates uniqueness of user' do + existing = create(:alumni_contribution) + alumni_contribution.user = existing.user + expect(alumni_contribution).not_to be_valid + end + end + + describe 'sponsoring_amount' do + it 'validates numericality is greater than or equal to 0' do + alumni_contribution.sponsoring_amount = -10.00 + expect(alumni_contribution).not_to be_valid + end + + it 'allows zero' do + alumni_contribution.sponsoring_amount = 0.00 + expect(alumni_contribution).to be_valid + end + + it 'allows nil' do + alumni_contribution.sponsoring_amount = nil + expect(alumni_contribution).to be_valid + end + end + + describe 'help_digtus' do + it 'validates inclusion in [true, false]' do + alumni_contribution.help_digtus = nil + expect(alumni_contribution).not_to be_valid + end + end + + describe 'help_kring' do + it 'validates inclusion in [true, false]' do + alumni_contribution.help_kring = nil + expect(alumni_contribution).not_to be_valid + end + end + + describe 'help_vereniging' do + it 'validates inclusion in [true, false]' do + alumni_contribution.help_vereniging = nil + expect(alumni_contribution).not_to be_valid + end + end + + describe 'help_anders' do + it 'validates maximum length of 1000' do + alumni_contribution.help_anders = 'a' * 1001 + expect(alumni_contribution).not_to be_valid + end + + it 'allows exactly 1000 characters' do + alumni_contribution.help_anders = 'a' * 1000 + expect(alumni_contribution).to be_valid + end + + it 'allows nil' do + alumni_contribution.help_anders = nil + expect(alumni_contribution).to be_valid + end + end + end + + describe 'database schema' do + it 'has user_id column' do + expect(described_class.column_names).to include('user_id') + end + + it 'has sponsoring_amount column' do + expect(described_class.column_names).to include('sponsoring_amount') + end + + it 'has help_digtus column' do + expect(described_class.column_names).to include('help_digtus') + end + + it 'has help_kring column' do + expect(described_class.column_names).to include('help_kring') + end + + it 'has help_vereniging column' do + expect(described_class.column_names).to include('help_vereniging') + end + + it 'has help_anders column' do + expect(described_class.column_names).to include('help_anders') + end + end +end diff --git a/spec/policies/alumni_contribution_policy_spec.rb b/spec/policies/alumni_contribution_policy_spec.rb new file mode 100644 index 00000000..b6c35a5a --- /dev/null +++ b/spec/policies/alumni_contribution_policy_spec.rb @@ -0,0 +1,56 @@ +require 'rails_helper' + +RSpec.describe AlumniContributionPolicy, type: :policy do + subject(:policy) { described_class } + + let(:user) { build_stubbed(:user) } + let(:record) { build_stubbed(:alumni_contribution) } + + permissions :index?, :show? do + describe 'when record is not owned and without permission' do + it { expect(policy).not_to permit(user, record) } + end + + describe 'when record is owned' do + let(:record) { build_stubbed(:alumni_contribution, user:) } + + it { expect(policy).to permit(user, record) } + end + + describe 'when with permission' do + let(:user) { create(:user, user_permission_list: ['alumni_contribution.read']) } + + it { expect(policy).to permit(user, record) } + end + end + + permissions :create? do + describe 'when without permission' do + it { expect(policy).not_to permit(user, record) } + end + + describe 'when with permission' do + let(:user) { create(:user, user_permission_list: ['alumni_contribution.create']) } + + it { expect(policy).to permit(user, record) } + end + end + + permissions :update?, :destroy? do + describe 'when record is not owned and without permission' do + it { expect(policy).not_to permit(user, record) } + end + + describe 'when record is owned' do + let(:record) { build_stubbed(:alumni_contribution, user:) } + + it { expect(policy).to permit(user, record) } + end + + describe 'when with permission' do + let(:user) { create(:user, user_permission_list: ['alumni_contribution.update']) } + + it { expect(policy).to permit(user, record) } + end + end +end diff --git a/spec/resources/v1/alumni_contribution_resource_spec.rb b/spec/resources/v1/alumni_contribution_resource_spec.rb new file mode 100644 index 00000000..890c13d1 --- /dev/null +++ b/spec/resources/v1/alumni_contribution_resource_spec.rb @@ -0,0 +1,20 @@ +require 'rails_helper' + +RSpec.describe V1::AlumniContributionResource, type: :resource do + let(:user) { create(:user) } + let(:context) { { user: } } + + describe '#creatable_fields' do + it { + expect(described_class.creatable_fields(context)).to match_array(%i[sponsoring_amount help_digtus help_kring + help_vereniging help_anders]) + } + end + + describe '#updatable_fields' do + it { + expect(described_class.updatable_fields(context)).to match_array(%i[sponsoring_amount help_digtus help_kring + help_vereniging help_anders]) + } + end +end