spree_core 5.6.0.rc4 → 5.6.0.rc6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 81bf3080a8d43c9bc6a6d76561c9fae751c9282a1e8b30dfb388ca6abe2511d7
4
- data.tar.gz: 423cf415d52c180297db3e26b581ea4078cfcf0e7816fc3084d12034b3d74a87
3
+ metadata.gz: 2118af5f16565f54bbb6e6bc66ed0db0bd44e8ad250b226dca1f2ae6dfd50dc9
4
+ data.tar.gz: 85cf95c960549e5557cb62c51d3b22516ce91d19b2532001c613fe2da036229c
5
5
  SHA512:
6
- metadata.gz: 351598372ce3b7d938815929ac76d050a45679aad1a4734c07a5642f932b81e0e688e0903538e2446c30516545083d2c6d52963b82598650d6e998606aeb8341
7
- data.tar.gz: 258324d4c6e23afbe5702782daf6e19811af60136b4f93bbd99d27824830ee7211426fde12f1330175132b4c82c281cfc6295ca0d7cd6f2cc400cce5e3f7e729
6
+ metadata.gz: 629b648d7bcc536638305dc942cc5fe8a08ddf9bcba35a7a4e132e84c72f486c106af32711d887b3b67097b35cac9b8664b3f8667630a6d90f56b3e1f815c974
7
+ data.tar.gz: 1077ccfe23cf6e5c98bd565a559551a344dec7ce20d0e3fe83b06dc9a8be02e74c5107d8edc48d2c0397c24248197d9097fdbb77896ed07b8f7bcd5da9a0d568
@@ -10,6 +10,19 @@ module Spree
10
10
  # taxon creation races).
11
11
  class BaseJob < Spree::BaseJob
12
12
  queue_as Spree.queues.imports
13
+
14
+ private
15
+
16
+ def with_store_content_locale(store, &block)
17
+ locale = store&.default_locale
18
+ return yield if locale.blank?
19
+
20
+ previous_content_locale = Spree::Current.content_locale
21
+ Spree::Current.content_locale = locale
22
+ I18n.with_locale(locale, &block)
23
+ ensure
24
+ Spree::Current.content_locale = previous_content_locale unless locale.blank?
25
+ end
13
26
  end
14
27
  end
15
28
  end
@@ -9,9 +9,11 @@ module Spree
9
9
  def perform(product_id, store_id, taxon_pretty_names)
10
10
  product = Spree::Product.find(product_id)
11
11
  store = Spree::Store.find(store_id)
12
- taxons = taxon_pretty_names.filter_map { |taxon_pretty_name| find_or_create_taxon(store, taxon_pretty_name) }
13
12
 
14
- product.taxons = taxons
13
+ with_store_content_locale(store) do
14
+ taxons = taxon_pretty_names.filter_map { |taxon_pretty_name| find_or_create_taxon(store, taxon_pretty_name) }
15
+ product.taxons = taxons
16
+ end
15
17
  end
16
18
 
17
19
  private
@@ -25,43 +25,45 @@ module Spree
25
25
  import = Spree::Import.find(import_id)
26
26
  Spree::Current.store = import.store
27
27
 
28
- mappings = import.mappings.mapped.to_a
29
- schema_fields = import.schema_fields
30
- large = import.large_import?
31
- grouped = import.group_column.present? && mappings.any? { |m| m.schema_field == import.group_column }
32
- started_at = Time.current
33
- processed_rows = []
28
+ with_store_content_locale(import.store) do
29
+ mappings = import.mappings.mapped.to_a
30
+ schema_fields = import.schema_fields
31
+ large = import.large_import?
32
+ grouped = import.group_column.present? && mappings.any? { |m| m.schema_field == import.group_column }
33
+ started_at = Time.current
34
+ processed_rows = []
34
35
 
35
- row_ids.each_slice(ROWS_BATCH_SIZE) do |ids|
36
- # Skip rows already completed on a prior attempt so retries don't double-process them.
37
- rows = import.rows.where(id: ids).pending_and_failed.order(:row_number).to_a
38
- # Share the already-loaded import across rows: each row's processor reads
39
- # `row.import` (store, ability, lookup cache), and without this every row
40
- # lazily loads its own Import instance and rebuilds all of that per row.
41
- rows.each { |row| row.association(:import).target = import }
36
+ row_ids.each_slice(ROWS_BATCH_SIZE) do |ids|
37
+ # Skip rows already completed on a prior attempt so retries don't double-process them.
38
+ rows = import.rows.where(id: ids).pending_and_failed.order(:row_number).to_a
39
+ # Share the already-loaded import across rows: each row's processor reads
40
+ # `row.import` (store, ability, lookup cache), and without this every row
41
+ # lazily loads its own Import instance and rebuilds all of that per row.
42
+ rows.each { |row| row.association(:import).target = import }
42
43
 
43
- if large
44
- Spree::Events.disable do
45
- rows.each { |row| row.bulk_process!(mappings: mappings, schema_fields: schema_fields) }
46
- end
47
- elsif grouped
48
- # A group is one product plus its variants: per-record lifecycle
49
- # events (variant.created, price.created, product.updated per
50
- # touch) are noise to subscribers — one product event is published
51
- # for the whole group below. import_row.* events still flow.
52
- Spree::Events.disable_lifecycle do
53
- rows.each { |row| row.process!(mappings: mappings, schema_fields: schema_fields) }
54
- end
55
- else
56
- rows.each do |row|
57
- row.process!(mappings: mappings, schema_fields: schema_fields)
44
+ if large
45
+ Spree::Events.disable do
46
+ rows.each { |row| row.bulk_process!(mappings: mappings, schema_fields: schema_fields) }
47
+ end
48
+ elsif grouped
49
+ # A group is one product plus its variants: per-record lifecycle
50
+ # events (variant.created, price.created, product.updated per
51
+ # touch) are noise to subscribers — one product event is published
52
+ # for the whole group below. import_row.* events still flow.
53
+ Spree::Events.disable_lifecycle do
54
+ rows.each { |row| row.process!(mappings: mappings, schema_fields: schema_fields) }
55
+ end
56
+ else
57
+ rows.each do |row|
58
+ row.process!(mappings: mappings, schema_fields: schema_fields)
59
+ end
58
60
  end
61
+ processed_rows.concat(rows)
59
62
  end
60
- processed_rows.concat(rows)
61
- end
62
63
 
63
- publish_group_events(processed_rows, started_at) if grouped
64
- check_import_completion(import, large)
64
+ publish_group_events(processed_rows, started_at) if grouped
65
+ check_import_completion(import, large)
66
+ end
65
67
  end
66
68
 
67
69
  private
@@ -17,7 +17,7 @@ module Spree
17
17
  extend ActiveSupport::Concern
18
18
 
19
19
  # Payload keys under `data` whose values are live credentials.
20
- SENSITIVE_PAYLOAD_KEYS = %w[reset_token unsubscribe_token].freeze
20
+ SENSITIVE_PAYLOAD_KEYS = %w[reset_token unsubscribe_token verification_token].freeze
21
21
 
22
22
  REDACTION_PLACEHOLDER = '[REDACTED]'
23
23
 
@@ -46,9 +46,15 @@ module Spree
46
46
 
47
47
  # This method should be called when a download is initiated.
48
48
  # It returns +true+ or +false+ depending on whether the authorization is granted.
49
+ #
50
+ # The access-limit check and the counter increment run inside a row lock so
51
+ # concurrent requests sharing the same token cannot each pass the cap before
52
+ # any of them increments the counter (TOCTOU race).
49
53
  def authorize!
50
54
  ActiveRecord::Base.connected_to(role: :writing) do
51
- authorizable? && increment!(:access_counter, touch: true)
55
+ with_lock do
56
+ authorizable? && increment!(:access_counter, touch: true)
57
+ end
52
58
  end
53
59
  end
54
60
 
@@ -62,7 +62,7 @@ module Spree
62
62
 
63
63
  def generate_code
64
64
  loop do
65
- code = "#{prefix.downcase}#{SecureRandom.hex(3).downcase}"
65
+ code = "#{prefix.downcase}#{SecureRandom.hex(8).downcase}"
66
66
  break code unless Spree::GiftCard.exists?(code: code) || @gift_cards_to_insert.detect { |gc| gc[:code] == code }
67
67
  end
68
68
  end
@@ -29,7 +29,15 @@ module Spree
29
29
 
30
30
  attribute :active, :boolean, default: true
31
31
 
32
+ # acts_as_list only assigns bottom positions in before_create — too late
33
+ # for the presence validation below, so API creates without an explicit
34
+ # position would 422. Default to the end of the channel's list instead.
35
+ before_validation :set_position_to_end, on: :create, if: -> { position.blank? && channel.present? }
36
+
32
37
  validates :type, :channel, presence: true
38
+ # One instance of each rule kind per channel — a duplicate signal would
39
+ # either be a no-op or fight itself in the reducer walk.
40
+ validates :type, uniqueness: { scope: [:channel_id, *spree_base_uniqueness_scope] }
33
41
  validates :position, presence: true, numericality: { only_integer: true }
34
42
  validate :channel_belongs_to_store
35
43
 
@@ -43,6 +51,25 @@ module Spree
43
51
 
44
52
  validate :type_must_be_registered
45
53
 
54
+ # @return [String] localized display name for the rule kind, used by admin pickers
55
+ def self.human_name
56
+ Spree.t("order_routing_rule_types.#{api_type}.name", default: api_type.titleize)
57
+ end
58
+
59
+ # @return [String] localized description for the rule kind
60
+ def self.human_description
61
+ Spree.t("order_routing_rule_types.#{api_type}.description", default: '')
62
+ end
63
+
64
+ # Feeds the `description` field of `subclasses_with_preference_schema`
65
+ # (the `/types` discovery payload), which only reads `.description`.
66
+ def self.description
67
+ human_description
68
+ end
69
+
70
+ def human_name = self.class.human_name
71
+ def human_description = self.class.human_description
72
+
46
73
  # Subclasses override. Returns an Array<LocationRanking> — one per location,
47
74
  # with rank=nil to abstain.
48
75
  #
@@ -53,8 +80,24 @@ module Spree
53
80
  raise NotImplementedError, "#{self.class} must implement #rank(order, locations)"
54
81
  end
55
82
 
83
+ # Routes Spree::PreferenceSchema's subclass discovery
84
+ # (`subclasses_with_preference_schema`, `find_by_api_type`) to the
85
+ # order-routing rule registry.
86
+ module ClassMethods
87
+ private
88
+
89
+ def registered_subclasses
90
+ Spree.order_routing.rules
91
+ end
92
+ end
93
+ extend ClassMethods
94
+
56
95
  private
57
96
 
97
+ def set_position_to_end
98
+ self.position = bottom_position_in_list + 1
99
+ end
100
+
58
101
  # The +type+ presence validation already covers blank; here we only reject
59
102
  # a present-but-unregistered STI type so arbitrary class names can't be
60
103
  # persisted via the +type+ column.
@@ -45,8 +45,9 @@ module Spree
45
45
  can :manage, Spree::ReimbursementType
46
46
  can :manage, Spree::ReturnReason
47
47
 
48
- # Channels
48
+ # Channels + per-channel order routing rules
49
49
  can :manage, Spree::Channel
50
+ can :manage, Spree::OrderRoutingRule
50
51
 
51
52
  # Restrictions on immutable types
52
53
  cannot [:edit, :update], Spree::RefundReason, mutable: false
@@ -24,6 +24,7 @@ module Spree
24
24
 
25
25
  acts_as_paranoid
26
26
  acts_as_taggable_on :tags, :labels
27
+ acts_as_taggable_tenant :store_id
27
28
  normalizes :name, with: ->(value) { value&.to_s&.squish&.presence }
28
29
 
29
30
  include Spree::ProductScopes
@@ -1190,6 +1190,16 @@ en:
1190
1190
  strategies:
1191
1191
  legacy: Legacy
1192
1192
  rules: Rules (ordered)
1193
+ order_routing_rule_types:
1194
+ default_location:
1195
+ description: Prefer the stock location marked as default
1196
+ name: Default location
1197
+ minimize_splits:
1198
+ description: Prefer the location that can fulfill the most items on its own, avoiding split shipments
1199
+ name: Minimize splits
1200
+ preferred_location:
1201
+ description: Fulfill from the location pinned on the order, when one is set
1202
+ name: Preferred location
1193
1203
  order_summary: Order Summary
1194
1204
  order_total: Order Total
1195
1205
  orders: Orders
@@ -0,0 +1,7 @@
1
+ class AddUniqueTypeIndexToSpreeOrderRoutingRules < ActiveRecord::Migration[7.2]
2
+ def change
3
+ add_index :spree_order_routing_rules, [:channel_id, :type],
4
+ unique: true,
5
+ name: 'idx_order_routing_rules_channel_type'
6
+ end
7
+ end
@@ -20,7 +20,19 @@ end
20
20
  wholesale_group.add_customers([buyer.id])
21
21
 
22
22
  price_list = store.price_lists.find_or_create_by!(name: 'Wholesale') do |list|
23
- list.description = 'Wholesale pricing for approved B2B buyers (40% off retail)'
23
+ list.description = 'Wholesale pricing for approved B2B buyers (40% off retail) on orders of 10+ per item'
24
+ end
25
+
26
+ # Case-pack minimum: the trade price applies only when a buyer orders at least
27
+ # WHOLESALE_MIN_QUANTITY of a single item (VolumeRule matches per line item, not
28
+ # per order). Below the threshold the buyer falls back to the retail price — no
29
+ # hard checkout block. This is what makes the demo feel like real wholesale.
30
+ wholesale_min_quantity = 10
31
+
32
+ unless price_list.price_rules.any? { |rule| rule.is_a?(Spree::PriceRules::VolumeRule) }
33
+ volume_rule = Spree::PriceRules::VolumeRule.new(price_list: price_list)
34
+ volume_rule.preferred_min_quantity = wholesale_min_quantity
35
+ volume_rule.save!
24
36
  end
25
37
 
26
38
  unless price_list.price_rules.any? { |rule| rule.is_a?(Spree::PriceRules::CustomerGroupRule) }
@@ -31,29 +43,29 @@ unless price_list.price_rules.any? { |rule| rule.is_a?(Spree::PriceRules::Custom
31
43
  rule.save!
32
44
  end
33
45
 
34
- if price_list.prices.none?
35
- currency = store.default_currency
36
- now = Time.current
37
- rows = Spree::Variant.joins(:product)
38
- .where(spree_products: { store_id: store.id })
39
- .joins(:prices)
40
- .where(spree_prices: { currency: currency, price_list_id: nil })
41
- .pluck(:id, Spree::Price.arel_table[:amount])
42
- .to_h # one row per variant — last base price wins
43
- .filter_map do |variant_id, amount|
46
+ # Same paths the admin UI uses: add_products materializes a blank row per
47
+ # variant × supported currency (skipping rows that already exist), then trade
48
+ # prices (40% off that currency's own retail price) fill in wherever a base
49
+ # price exists and the row is still blank. Both steps are idempotent, so
50
+ # re-running sample data converges partial state — new products, newly
51
+ # supported currencies, or a previously interrupted run — without clobbering
52
+ # hand-edited amounts.
53
+ price_list.add_products(store.products.ids)
54
+
55
+ blank_prices = price_list.prices.where(amount: nil).pluck(:id, :variant_id, :currency)
56
+ if blank_prices.any?
57
+ base_amounts = Spree::Price.where(price_list_id: nil, variant_id: blank_prices.map { |_, variant_id, _| variant_id }.uniq)
58
+ .pluck(:variant_id, :currency, :amount)
59
+ .each_with_object({}) { |(variant_id, currency, amount), memo| memo[[variant_id, currency]] = amount }
60
+
61
+ rows = blank_prices.filter_map do |id, variant_id, currency|
62
+ amount = base_amounts[[variant_id, currency]]
44
63
  next if amount.blank?
45
64
 
46
- {
47
- variant_id: variant_id,
48
- price_list_id: price_list.id,
49
- amount: (amount * 0.6).round(2),
50
- currency: currency,
51
- created_at: now,
52
- updated_at: now
53
- }
65
+ { id: id, variant_id: variant_id, currency: currency, amount: (amount * 0.6).round(2) }
54
66
  end
55
67
 
56
- Spree::Price.insert_all(rows) if rows.any?
68
+ price_list.bulk_update_prices(rows) if rows.any?
57
69
  end
58
70
 
59
71
  price_list.activate if price_list.can_activate?
@@ -1,5 +1,5 @@
1
1
  module Spree
2
- VERSION = '5.6.0.rc4'.freeze
2
+ VERSION = '5.6.0.rc6'.freeze
3
3
 
4
4
  def self.version
5
5
  VERSION
@@ -0,0 +1,14 @@
1
+ FactoryBot.define do
2
+ # STI base can't be persisted (type must be a registered subclass), so the
3
+ # factory defaults to DefaultLocation. Channels seed all built-in kinds on
4
+ # create and `type` is unique per channel, so the factory clears the seeded
5
+ # list — specs get a pristine channel whose only rule is the one built here.
6
+ # Position defaults to the end of the channel's list via the model callback.
7
+ factory :order_routing_rule, class: Spree::OrderRouting::Rules::DefaultLocation do
8
+ channel { association(:channel).tap { |c| c.order_routing_rules.delete_all } }
9
+ store { channel.store }
10
+
11
+ factory :preferred_location_routing_rule, class: Spree::OrderRouting::Rules::PreferredLocation
12
+ factory :minimize_splits_routing_rule, class: Spree::OrderRouting::Rules::MinimizeSplits
13
+ end
14
+ end
@@ -57,3 +57,13 @@ steps:
57
57
  notes: |
58
58
  Recomputes the descendant-inclusive products_count counter cache on every
59
59
  taxon in batches.
60
+
61
+ - id: product_tag_tenants
62
+ name: "Backfill tenant on product taggings"
63
+ task: "spree:upgrade:backfill_product_tag_tenants"
64
+ notes: |
65
+ Sets the tenant column on existing Spree::Product taggings from each
66
+ product's store_id, so product tag autocomplete is bounded to the owning
67
+ store (matching Spree::Order). New product taggings tenant themselves;
68
+ only rows created before the upgrade need this. Until it runs, older
69
+ product tags are hidden from the store-scoped tag vocabulary.
@@ -0,0 +1,26 @@
1
+ namespace :spree do
2
+ namespace :upgrade do
3
+ desc <<~DESC
4
+ Backfills the +tenant+ column on existing +Spree::Product+ taggings from
5
+ each product's +store_id+, so product tag vocabulary is bounded to the
6
+ owning store (matching +Spree::Order+, which already tenants its taggings).
7
+
8
+ Run once after upgrading to the release that adds
9
+ +acts_as_taggable_tenant :store_id+ to +Spree::Product+. New product
10
+ taggings get their tenant automatically; only rows created before the
11
+ upgrade need this. Idempotent — re-running only touches rows still
12
+ missing a tenant.
13
+ DESC
14
+ task backfill_product_tag_tenants: :environment do
15
+ taggings = ActsAsTaggableOn::Tagging.arel_table.name
16
+ products = Spree::Product.table_name
17
+
18
+ updated = ActsAsTaggableOn::Tagging.
19
+ where(taggable_type: 'Spree::Product', tenant: nil).
20
+ joins("INNER JOIN #{products} ON #{products}.id = #{taggings}.taggable_id").
21
+ update_all("tenant = #{products}.store_id")
22
+
23
+ puts " Backfilled tenant on #{updated} product tagging(s)."
24
+ end
25
+ end
26
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: spree_core
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.6.0.rc4
4
+ version: 5.6.0.rc6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sean Schofield
@@ -10,7 +10,7 @@ authors:
10
10
  autorequire:
11
11
  bindir: bin
12
12
  cert_chain: []
13
- date: 2026-07-20 00:00:00.000000000 Z
13
+ date: 2026-07-23 00:00:00.000000000 Z
14
14
  dependencies:
15
15
  - !ruby/object:Gem::Dependency
16
16
  name: i18n-tasks
@@ -1604,6 +1604,7 @@ files:
1604
1604
  - db/migrate/20260710000001_add_import_id_status_index_to_spree_import_rows.rb
1605
1605
  - db/migrate/20260711000001_add_preferences_to_spree_exports.rb
1606
1606
  - db/migrate/20260719000001_add_channel_id_to_spree_api_keys.rb
1607
+ - db/migrate/20260721000001_add_unique_type_index_to_spree_order_routing_rules.rb
1607
1608
  - db/sample_data/channels.rb
1608
1609
  - db/sample_data/customers.csv
1609
1610
  - db/sample_data/markets.rb
@@ -1748,6 +1749,7 @@ files:
1748
1749
  - lib/spree/testing_support/factories/options_factory.rb
1749
1750
  - lib/spree/testing_support/factories/order_factory.rb
1750
1751
  - lib/spree/testing_support/factories/order_promotion_factory.rb
1752
+ - lib/spree/testing_support/factories/order_routing_rule_factory.rb
1751
1753
  - lib/spree/testing_support/factories/payment_capture_event_factory.rb
1752
1754
  - lib/spree/testing_support/factories/payment_factory.rb
1753
1755
  - lib/spree/testing_support/factories/payment_method_factory.rb
@@ -1835,6 +1837,7 @@ files:
1835
1837
  - lib/tasks/markets.rake
1836
1838
  - lib/tasks/media.rake
1837
1839
  - lib/tasks/price_history.rake
1840
+ - lib/tasks/product_tag_tenants.rake
1838
1841
  - lib/tasks/products.rake
1839
1842
  - lib/tasks/publications.rake
1840
1843
  - lib/tasks/role_users.rake
@@ -1865,9 +1868,9 @@ licenses:
1865
1868
  - BSD-3-Clause
1866
1869
  metadata:
1867
1870
  bug_tracker_uri: https://github.com/spree/spree/issues
1868
- changelog_uri: https://github.com/spree/spree/releases/tag/v5.6.0.rc4
1871
+ changelog_uri: https://github.com/spree/spree/releases/tag/v5.6.0.rc6
1869
1872
  documentation_uri: https://docs.spreecommerce.org/
1870
- source_code_uri: https://github.com/spree/spree/tree/v5.6.0.rc4
1873
+ source_code_uri: https://github.com/spree/spree/tree/v5.6.0.rc6
1871
1874
  post_install_message:
1872
1875
  rdoc_options: []
1873
1876
  require_paths: