diff --git a/.cloud66/manifest.yml b/.cloud66/manifest.yml new file mode 100644 index 0000000000..da5448e6e5 --- /dev/null +++ b/.cloud66/manifest.yml @@ -0,0 +1,3 @@ +rails: + configuration: + ruby_version: 3.2.2 diff --git a/.ruby-version b/.ruby-version index ef538c2810..be94e6f53d 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.1.2 +3.2.2 diff --git a/Gemfile b/Gemfile index 2720484e1f..3ff38e6a9d 100644 --- a/Gemfile +++ b/Gemfile @@ -5,8 +5,6 @@ git_source(:github) do |repo_name| "https://github.com/#{repo_name}.git" end -ruby "3.1.2" - ###### BASIC FRAMEWORKS ###### # User management and login workflow. @@ -202,7 +200,7 @@ group :test do # More concise test ("should") matchers gem 'shoulda-matchers', '~> 5.3' # Selenium webdriver automatic installation and update. - gem 'selenium-webdriver', '~> 4.10' + gem 'selenium-webdriver', '~> 4.16' # Mock HTTP requests and ensure they are not called during tests. gem "webmock", "~> 3.19" end diff --git a/Gemfile.lock b/Gemfile.lock index eaaaa88cf2..fb532f8913 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -96,12 +96,12 @@ GEM autoprefixer-rails (>= 9.1.0) popper_js (>= 2.11.6, < 3) sassc-rails (>= 2.0.0) - brakeman (6.0.1) + brakeman (6.1.0) brow (0.4.1) bugsnag (6.26.0) concurrent-ruby (~> 1.0) builder (3.2.4) - bullet (7.1.3) + bullet (7.1.4) activesupport (>= 3.0.0) uniform_notifier (~> 1.11) capybara (3.39.2) @@ -262,7 +262,7 @@ GEM multi_xml (>= 0.5.2) i18n (1.14.1) concurrent-ruby (~> 1.0) - icalendar (2.10.0) + icalendar (2.10.1) ice_cube (~> 0.16) ice_cube (0.16.4) ice_nine (0.11.2) @@ -276,7 +276,7 @@ GEM jbuilder (2.11.5) actionview (>= 5.0.0) activesupport (>= 5.0.0) - json (2.6.3) + json (2.7.1) jwt (2.7.1) kaminari (1.2.2) activesupport (>= 4.1.0) @@ -290,7 +290,7 @@ GEM activerecord kaminari-core (= 1.2.2) kaminari-core (1.2.2) - knapsack_pro (5.7.0) + knapsack_pro (6.0.3) rake language_server-protocol (3.17.0.3) launchy (2.5.2) @@ -359,11 +359,11 @@ GEM newrelic_rpm (9.6.0) base64 nio4r (2.5.9) - nokogiri (1.15.4-arm64-darwin) + nokogiri (1.15.5-arm64-darwin) racc (~> 1.4) - nokogiri (1.15.4-x86_64-darwin) + nokogiri (1.15.5-x86_64-darwin) racc (~> 1.4) - nokogiri (1.15.4-x86_64-linux) + nokogiri (1.15.5-x86_64-linux) racc (~> 1.4) notiffany (0.1.3) nenv (~> 0.1) @@ -486,7 +486,7 @@ GEM redis-client (>= 0.17.0) redis-client (0.18.0) connection_pool - regexp_parser (2.8.2) + regexp_parser (2.8.3) request_store (1.5.1) rack (>= 1.4) responders (3.1.1) @@ -561,7 +561,7 @@ GEM sprockets (> 3.0) sprockets-rails tilt - selenium-webdriver (4.15.0) + selenium-webdriver (4.16.0) rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) @@ -593,7 +593,7 @@ GEM actionpack (>= 5.2) activesupport (>= 5.2) sprockets (>= 3.0.0) - standard (1.32.0) + standard (1.32.1) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.0) rubocop (~> 1.57.2) @@ -609,7 +609,7 @@ GEM railties (>= 6.0.0) strong_migrations (1.6.4) activerecord (>= 5.2) - terser (1.1.19) + terser (1.1.20) execjs (>= 0.3.0, < 3) thor (1.3.0) tilt (2.2.0) @@ -729,7 +729,7 @@ DEPENDENCIES rubocop-rails (~> 2.22.2) rufus-scheduler sass-rails - selenium-webdriver (~> 4.10) + selenium-webdriver (~> 4.16) shoulda-matchers (~> 5.3) simple_form simplecov @@ -742,8 +742,5 @@ DEPENDENCIES web-console webmock (~> 3.19) -RUBY VERSION - ruby 3.1.2p20 - BUNDLED WITH 2.4.22 diff --git a/app/events/adjustment_event.rb b/app/events/adjustment_event.rb index a472e5f744..e0f1978c57 100644 --- a/app/events/adjustment_event.rb +++ b/app/events/adjustment_event.rb @@ -4,7 +4,7 @@ def self.publish(adjustment) create( eventable: adjustment, organization_id: adjustment.organization_id, - event_time: Time.zone.now, + event_time: adjustment.created_at, data: EventTypes::InventoryPayload.new( items: EventTypes::EventLineItem.from_line_items(adjustment.line_items, to: adjustment.storage_location_id) ) diff --git a/app/events/audit_event.rb b/app/events/audit_event.rb index 247400ae94..c4ed421e8e 100644 --- a/app/events/audit_event.rb +++ b/app/events/audit_event.rb @@ -6,7 +6,7 @@ def self.publish(audit) create( eventable: audit, organization_id: audit.organization_id, - event_time: Time.zone.now, + event_time: audit.updated_at, data: EventTypes::AuditPayload.new( storage_location_id: audit.storage_location_id, items: EventTypes::EventLineItem.from_line_items(audit.line_items, to: audit.storage_location_id) diff --git a/app/events/distribution_event.rb b/app/events/distribution_event.rb index 6efc7f6984..11a256172a 100644 --- a/app/events/distribution_event.rb +++ b/app/events/distribution_event.rb @@ -4,7 +4,7 @@ def self.publish(distribution) create( eventable: distribution, organization_id: distribution.organization_id, - event_time: Time.zone.now, + event_time: distribution.created_at, data: EventTypes::InventoryPayload.new( items: EventTypes::EventLineItem.from_line_items(distribution.line_items, from: distribution.storage_location_id) ) diff --git a/app/events/donation_event.rb b/app/events/donation_event.rb index 83f5207d76..adf923c461 100644 --- a/app/events/donation_event.rb +++ b/app/events/donation_event.rb @@ -4,7 +4,7 @@ def self.publish(donation) create( eventable: donation, organization_id: donation.organization_id, - event_time: Time.zone.now, + event_time: donation.created_at, data: EventTypes::InventoryPayload.new( items: EventTypes::EventLineItem.from_line_items(donation.line_items, to: donation.storage_location_id) ) diff --git a/app/events/event_types/inventory.rb b/app/events/event_types/inventory.rb index 5c3da47279..60cd032e04 100644 --- a/app/events/event_types/inventory.rb +++ b/app/events/event_types/inventory.rb @@ -16,6 +16,19 @@ def self.from(organization_id) storage_locations: org.storage_locations.map { |s| [s.id, EventTypes::EventStorageLocation.from(s)] }.to_h) end + # @param item_id [Integer] + # @param quantity [Integer] + # @param location [Integer] + def set_item_quantity(item_id:, quantity:, location:) + storage_locations[location] ||= EventTypes::EventStorageLocation.new(id: location, items: {}) + storage_locations[location].set_inventory(item_id, quantity) + end + + # @param item_id [Integer] + # @param quantity [Integer] + # @param from_location [Integer] + # @param to_location [Integer] + # @param validate [Boolean] def move_item(item_id:, quantity:, from_location: nil, to_location: nil, validate: true) if from_location if storage_locations[from_location].nil? && validate diff --git a/app/events/inventory_aggregate.rb b/app/events/inventory_aggregate.rb index dada21f0f8..e1bdb995ae 100644 --- a/app/events/inventory_aggregate.rb +++ b/app/events/inventory_aggregate.rb @@ -8,27 +8,37 @@ def on(*event_types, &block) end end - # @param organization_id + # @param organization_id [Integer] + # @param first_event [Integer] + # @param last_event [Integer] + # @param validate [Boolean] # @return [EventTypes::Inventory] - def inventory_for(organization_id) + def inventory_for(organization_id, first_event: nil, last_event: nil, validate: false) events = Event.for_organization(organization_id) + if first_event + events = events.where("id >= ?", first_event) + end + if last_event + events = events.where("id <= ?", last_event) + end inventory = EventTypes::Inventory.from(organization_id) events.group_by { |e| [e.eventable_type, e.eventable_id] }.each do |_, event_batch| - last_event = event_batch.max_by(&:event_time) - handle(last_event, inventory) + last_grouped_event = event_batch.max_by(&:updated_at) + handle(last_grouped_event, inventory, validate: validate) end inventory end # @param event [Event] # @param inventory [Inventory] - def handle(event, inventory) + # @param validate [Boolean] + def handle(event, inventory, validate: false) handler = @handlers[event.class] if handler.nil? Rails.logger.warn("No handler found for #{event.class}, skipping") return end - handler.call(event, inventory) + handler.call(event, inventory, validate: validate) end # @param payload [EventTypes::InventoryPayload] @@ -43,21 +53,30 @@ def handle_inventory_event(payload, inventory, validate: true) validate: validate) end end + + # @param payload [EventTypes::InventoryPayload] + # @param inventory [EventTypes::Inventory] + def handle_audit_event(payload, inventory) + payload.items.each do |line_item| + inventory.set_item_quantity(item_id: line_item.item_id, + quantity: line_item.quantity, + location: line_item.to_storage_location) + end + end end on DonationEvent, DistributionEvent, AdjustmentEvent, PurchaseEvent, TransferEvent, DistributionDestroyEvent, DonationDestroyEvent, PurchaseDestroyEvent, TransferDestroyEvent, - KitAllocateEvent, KitDeallocateEvent do |event, inventory| - handle_inventory_event(event.data, inventory, validate: false) + KitAllocateEvent, KitDeallocateEvent do |event, inventory, validate: false| + handle_inventory_event(event.data, inventory, validate: validate) end - on AuditEvent do |event, inventory| - inventory.storage_locations[event.data.storage_location_id].reset! - handle_inventory_event(event.data, inventory, validate: false) + on AuditEvent do |event, inventory, validate: false| + handle_audit_event(event.data, inventory) end - on SnapshotEvent do |event, inventory| + on SnapshotEvent do |event, inventory, validate: false| inventory.storage_locations.clear inventory.storage_locations.merge!(event.data.storage_locations) end diff --git a/app/events/purchase_event.rb b/app/events/purchase_event.rb index 910c295916..ac00a67003 100644 --- a/app/events/purchase_event.rb +++ b/app/events/purchase_event.rb @@ -4,7 +4,7 @@ def self.publish(purchase) create( eventable: purchase, organization_id: purchase.organization_id, - event_time: Time.zone.now, + event_time: purchase.created_at, data: EventTypes::InventoryPayload.new( items: EventTypes::EventLineItem.from_line_items(purchase.line_items, to: purchase.storage_location_id) ) diff --git a/app/events/transfer_event.rb b/app/events/transfer_event.rb index e0343a4028..f8cf4f9624 100644 --- a/app/events/transfer_event.rb +++ b/app/events/transfer_event.rb @@ -4,7 +4,7 @@ def self.publish(transfer) create( eventable: transfer, organization_id: transfer.organization_id, - event_time: Time.zone.now, + event_time: transfer.created_at, data: EventTypes::InventoryPayload.new( items: EventTypes::EventLineItem.from_line_items(transfer.line_items, from: transfer.from.id, diff --git a/app/models/partner.rb b/app/models/partner.rb index 23c8dec134..392ac9a388 100644 --- a/app/models/partner.rb +++ b/app/models/partner.rb @@ -235,6 +235,17 @@ def invite_new_partner end def should_invite_because_email_changed? - email_changed? and (invited? or awaiting_review? or recertification_required? or approved?) + email_changed? && + ( + invited? || + awaiting_review? || + recertification_required? || + approved? + ) && + !partner_user_with_same_email_exist? + end + + def partner_user_with_same_email_exist? + User.exists?(email: email) && User.find_by(email: email).has_role?(Role::PARTNER, self) end end diff --git a/app/views/users/mailer/reset_password_instructions.html.erb b/app/views/users/mailer/reset_password_instructions.html.erb index 65def06f7c..e3f1a5ce9f 100644 --- a/app/views/users/mailer/reset_password_instructions.html.erb +++ b/app/views/users/mailer/reset_password_instructions.html.erb @@ -1,5 +1,7 @@

Hello <%= @resource.email %>!

+

This is an automated email from the human essentials application. Please contact your diaper bank directly with any request-related questions.

+

Someone has requested a link to change your password. You can do this through the link below.

<%= link_to 'Change my password', edit_password_url(@resource, reset_password_token: @token) %>

diff --git a/db/migrate/20231201194409_fix_event_times.rb b/db/migrate/20231201194409_fix_event_times.rb new file mode 100644 index 0000000000..f6ec957a82 --- /dev/null +++ b/db/migrate/20231201194409_fix_event_times.rb @@ -0,0 +1,9 @@ +class FixEventTimes < ActiveRecord::Migration[7.0] + def change + Event.where(type: %i(DistributionEvent DonationEvent PurchaseEvent)).find_each do |event| + next if event.eventable.nil? + + event.update_attribute(:event_time, event.eventable.created_at) + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 6fa13acbfc..54906d1330 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.0].define(version: 2023_11_17_141301) do +ActiveRecord::Schema[7.0].define(version: 2023_12_01_194409) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" diff --git a/spec/events/inventory_aggregate_spec.rb b/spec/events/inventory_aggregate_spec.rb index cd9c066f62..f310d1be94 100644 --- a/spec/events/inventory_aggregate_spec.rb +++ b/spec/events/inventory_aggregate_spec.rb @@ -336,6 +336,7 @@ id: storage_location1.id, items: { item1.id => EventTypes::EventItem.new(item_id: item1.id, quantity: 20), + item2.id => EventTypes::EventItem.new(item_id: item2.id, quantity: 10), item3.id => EventTypes::EventItem.new(item_id: item3.id, quantity: 10) } ), @@ -446,50 +447,92 @@ end end - it "should process multiple events" do - donation = FactoryBot.create(:donation, organization: organization, storage_location: storage_location1) - donation.line_items << build(:line_item, quantity: 50, item: item1) - donation.line_items << build(:line_item, quantity: 30, item: item2) - DonationEvent.publish(donation) - - donation2 = FactoryBot.create(:donation, organization: organization, storage_location: storage_location1) - donation2.line_items << build(:line_item, quantity: 30, item: item1) - DonationEvent.publish(donation2) - - donation3 = FactoryBot.create(:donation, organization: organization, storage_location: storage_location2) - donation3.line_items << build(:line_item, quantity: 50, item: item2) - DonationEvent.publish(donation3) - - # correction event - donation3.line_items = [build(:line_item, quantity: 40, item: item2)] - DonationEvent.publish(donation3) - - dist = FactoryBot.create(:distribution, organization: organization, storage_location: storage_location1) - dist.line_items << build(:line_item, quantity: 10, item: item1) - DistributionEvent.publish(dist) - - dist2 = FactoryBot.create(:distribution, organization: organization, storage_location: storage_location2) - dist2.line_items << build(:line_item, quantity: 15, item: item2) - DistributionEvent.publish(dist2) - - inventory = described_class.inventory_for(organization.id) - expect(inventory).to eq(EventTypes::Inventory.new( - organization_id: organization.id, - storage_locations: { - storage_location1.id => EventTypes::EventStorageLocation.new( - id: storage_location1.id, - items: { - item1.id => EventTypes::EventItem.new(item_id: item1.id, quantity: 70), - item2.id => EventTypes::EventItem.new(item_id: item2.id, quantity: 30) - } - ), - storage_location2.id => EventTypes::EventStorageLocation.new( - id: storage_location2.id, - items: { - item2.id => EventTypes::EventItem.new(item_id: item2.id, quantity: 25) - } - ) - } - )) + describe "multiple events" do + it "should process multiple events" do + donation = FactoryBot.create(:donation, organization: organization, storage_location: storage_location1) + donation.line_items << build(:line_item, quantity: 50, item: item1) + donation.line_items << build(:line_item, quantity: 30, item: item2) + DonationEvent.publish(donation) + + donation2 = FactoryBot.create(:donation, organization: organization, storage_location: storage_location1) + donation2.line_items << build(:line_item, quantity: 30, item: item1) + DonationEvent.publish(donation2) + + donation3 = FactoryBot.create(:donation, organization: organization, storage_location: storage_location2) + donation3.line_items << build(:line_item, quantity: 50, item: item2) + DonationEvent.publish(donation3) + + # correction event + donation3.line_items = [build(:line_item, quantity: 40, item: item2)] + DonationEvent.publish(donation3) + + dist = FactoryBot.create(:distribution, organization: organization, storage_location: storage_location1) + dist.line_items << build(:line_item, quantity: 10, item: item1) + DistributionEvent.publish(dist) + + dist2 = FactoryBot.create(:distribution, organization: organization, storage_location: storage_location2) + dist2.line_items << build(:line_item, quantity: 15, item: item2) + DistributionEvent.publish(dist2) + + inventory = described_class.inventory_for(organization.id) + expect(inventory).to eq(EventTypes::Inventory.new( + organization_id: organization.id, + storage_locations: { + storage_location1.id => EventTypes::EventStorageLocation.new( + id: storage_location1.id, + items: { + item1.id => EventTypes::EventItem.new(item_id: item1.id, quantity: 70), + item2.id => EventTypes::EventItem.new(item_id: item2.id, quantity: 30) + } + ), + storage_location2.id => EventTypes::EventStorageLocation.new( + id: storage_location2.id, + items: { + item2.id => EventTypes::EventItem.new(item_id: item2.id, quantity: 25) + } + ) + } + )) + end + + it "should validate incorrect events" do + donation = FactoryBot.create(:donation, organization: organization, storage_location: storage_location1) + donation.line_items << build(:line_item, quantity: 10, item: item1) + DonationEvent.publish(donation) + + dist = FactoryBot.create(:distribution, organization: organization, storage_location: storage_location1) + dist.line_items << build(:line_item, quantity: 20, item: item1) + DistributionEvent.publish(dist) + + expect { described_class.inventory_for(organization.id, validate: true) } + .to raise_error("Could not reduce quantity by 20 for item #{item1.id} in storage location #{storage_location1.id} - current quantity is 10") + end + + it "should handle timing correctly" do + donation = FactoryBot.create(:donation, organization: organization, storage_location: storage_location1) + donation.line_items << build(:line_item, quantity: 30, item: item1) + DonationEvent.publish(donation) + + dist = FactoryBot.create(:distribution, organization: organization, storage_location: storage_location1) + dist.line_items << build(:line_item, quantity: 10, item: item1) + DistributionEvent.publish(dist) + + # correction event + donation.line_items[0].quantity = 20 + DonationEvent.publish(donation) + + inventory = described_class.inventory_for(organization.id, validate: true) + expect(inventory).to eq(EventTypes::Inventory.new( + organization_id: organization.id, + storage_locations: { + storage_location1.id => EventTypes::EventStorageLocation.new( + id: storage_location1.id, + items: { + item1.id => EventTypes::EventItem.new(item_id: item1.id, quantity: 10) + } + ) + } + )) + end end end diff --git a/spec/models/partner_spec.rb b/spec/models/partner_spec.rb index 5f7f97b4be..8a9ab494f4 100644 --- a/spec/models/partner_spec.rb +++ b/spec/models/partner_spec.rb @@ -218,6 +218,24 @@ end end + it "should not call the UserInviteService.invite when the partner is changing email to previously used email" do + previous_email = partner.email + partner.email = "randomtest@email.com" + partner.save! + partner.email = previous_email + partner.save! + expect(UserInviteService).not_to have_received(:invite).with( + email: previous_email, + roles: [Role::PARTNER], + resource: partner + ) + expect(UserInviteService).to have_received(:invite).with( + email: "randomtest@email.com", + roles: [Role::PARTNER], + resource: partner + ) + end + [:uninvited, :deactivated].each do |test_status| it "should not call the UserInviteService.invite when the partner has status #{test_status} and the email is changed" do partner.status = test_status