spree_core 6.0.0.rc5 → 6.0.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.
Files changed (98) hide show
  1. checksums.yaml +4 -4
  2. data/app/jobs/spree/images/save_from_url_job.rb +24 -5
  3. data/app/models/concerns/spree/account_deactivation.rb +54 -0
  4. data/app/models/concerns/spree/calculator/currency_amounts.rb +19 -6
  5. data/app/models/concerns/spree/derived_number.rb +1 -1
  6. data/app/models/concerns/spree/has_external_references.rb +24 -16
  7. data/app/models/concerns/spree/prefixed_id.rb +15 -5
  8. data/app/models/concerns/spree/publishable.rb +47 -1
  9. data/app/models/concerns/spree/purchase/addresses.rb +19 -4
  10. data/app/models/concerns/spree/purchase/digital_items.rb +17 -3
  11. data/app/models/concerns/spree/purchase/freight.rb +2 -1
  12. data/app/models/concerns/spree/purchase/payment_processing.rb +7 -1
  13. data/app/models/concerns/spree/purchase/quantity_rules.rb +2 -0
  14. data/app/models/concerns/spree/stores/channels.rb +3 -1
  15. data/app/models/concerns/spree/translatable_resource.rb +6 -1
  16. data/app/models/spree/admin_user.rb +1 -0
  17. data/app/models/spree/base.rb +33 -1
  18. data/app/models/spree/calculator/shipping/digital_delivery.rb +3 -1
  19. data/app/models/spree/calculator/shipping/flat_rate.rb +3 -1
  20. data/app/models/spree/calculator/shipping/per_item.rb +3 -1
  21. data/app/models/spree/cart.rb +4 -1
  22. data/app/models/spree/category.rb +76 -5
  23. data/app/models/spree/channel.rb +79 -40
  24. data/app/models/spree/company.rb +45 -2
  25. data/app/models/spree/customer.rb +1 -1
  26. data/app/models/spree/customer_group.rb +59 -41
  27. data/app/models/spree/delivery_method.rb +18 -8
  28. data/app/models/spree/delivery_profile.rb +18 -0
  29. data/app/models/spree/digital_asset.rb +14 -0
  30. data/app/models/spree/event.rb +20 -5
  31. data/app/models/spree/fulfillment.rb +8 -1
  32. data/app/models/spree/fulfillment_changer.rb +3 -1
  33. data/app/models/spree/fulfillment_item.rb +11 -0
  34. data/app/models/spree/import.rb +20 -2
  35. data/app/models/spree/order_routing/strategy/rules.rb +4 -1
  36. data/app/models/spree/price_list.rb +15 -0
  37. data/app/models/spree/product/channels.rb +16 -8
  38. data/app/models/spree/product.rb +7 -2
  39. data/app/models/spree/return.rb +8 -1
  40. data/app/models/spree/search_provider/base.rb +8 -3
  41. data/app/models/spree/search_provider/database.rb +2 -10
  42. data/app/models/spree/seller.rb +35 -5
  43. data/app/models/spree/seller_balance.rb +28 -0
  44. data/app/models/spree/seller_requirements/delivery_method.rb +20 -2
  45. data/app/models/spree/stock/coordinator.rb +7 -4
  46. data/app/models/spree/stock/estimator.rb +6 -0
  47. data/app/models/spree/stock/inventory_unit_builder.rb +14 -2
  48. data/app/models/spree/stock/packer.rb +39 -5
  49. data/app/models/spree/stock/quantifier.rb +16 -0
  50. data/app/models/spree/stock_location.rb +15 -8
  51. data/app/models/spree/store.rb +31 -2
  52. data/app/models/spree/store_credit.rb +4 -4
  53. data/app/models/spree/variant.rb +23 -6
  54. data/app/models/spree/webhook_endpoint.rb +45 -11
  55. data/app/services/spree/carts/price_items.rb +3 -0
  56. data/app/services/spree/carts/split_by_seller.rb +3 -1
  57. data/app/services/spree/catalogs/set_product_terms.rb +8 -3
  58. data/app/services/spree/collections/add_products.rb +1 -1
  59. data/app/services/spree/collections/regenerate_products.rb +1 -3
  60. data/app/services/spree/collections/remove_products.rb +1 -1
  61. data/app/services/spree/newsletter/subscribe.rb +9 -1
  62. data/app/services/spree/order_groups/allocate_payments.rb +7 -5
  63. data/app/services/spree/orders/update.rb +19 -0
  64. data/app/services/spree/product_memberships.rb +16 -6
  65. data/app/services/spree/products/duplicator.rb +12 -4
  66. data/app/services/spree/search_provider/custom_field_schema.rb +10 -2
  67. data/app/services/spree/stock_levels/correct.rb +11 -4
  68. data/app/services/spree/stock_reservations/reserve.rb +1 -1
  69. data/app/services/spree/stores/provision_defaults.rb +11 -7
  70. data/app/services/spree/tags/bulk_add.rb +16 -6
  71. data/app/workflows/spree/carts/complete.rb +25 -9
  72. data/app/workflows/spree/carts/upsert_items.rb +31 -4
  73. data/app/workflows/spree/customers/anonymize.rb +2 -2
  74. data/app/workflows/spree/digital_assets/create.rb +56 -0
  75. data/app/workflows/spree/digital_assets/update.rb +44 -0
  76. data/app/workflows/spree/digital_assets/writing.rb +108 -0
  77. data/app/workflows/spree/imports/start_mapping.rb +3 -1
  78. data/app/workflows/spree/orders/complete.rb +12 -5
  79. data/app/workflows/spree/payments/capture.rb +12 -0
  80. data/app/workflows/spree/products/nested_attributes.rb +12 -28
  81. data/app/workflows/spree/products/propose.rb +9 -4
  82. data/app/workflows/spree/stock_levels/bulk_upsert.rb +28 -13
  83. data/app/workflows/spree/variants/nested_attributes.rb +35 -10
  84. data/config/locales/en.yml +10 -0
  85. data/db/migrate/20261022000001_store_credit_actor_polymorphic.rb +13 -0
  86. data/db/migrate/20261023100001_add_deactivated_at_to_spree_admin_users.rb +7 -0
  87. data/lib/generators/spree/dummy/templates/rails/test.rb +87 -0
  88. data/lib/spree/checkout/default_requirements.rb +12 -2
  89. data/lib/spree/core/dependencies.rb +5 -0
  90. data/lib/spree/core/permissions/default_catalog.rb +7 -2
  91. data/lib/spree/core/version.rb +1 -1
  92. data/lib/spree/reporting/default_vocabulary.rb +1 -1
  93. data/lib/spree/testing_support/factories/product_factory.rb +10 -4
  94. data/lib/spree/testing_support/factories/store_factory.rb +4 -1
  95. data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +10 -0
  96. data/lib/tasks/backfill_tagging_tenants.rake +35 -0
  97. data/lib/tasks/seller_sample_data.rake +114 -112
  98. metadata +13 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9e7960e9257c5de15c7d9e69d8916c669c5ab082fccd02e17c5ee9c9d9d378c8
4
- data.tar.gz: 3c160cd58beb2d3fc3c99bead42bc0d3f365952ae7c2b2c103d92004303f7d13
3
+ metadata.gz: 6ac170eb6008053ca9eea6d6694bb98d49cc4e66e28826f4aac5bc97fad29e15
4
+ data.tar.gz: 8a883557c01ab2211232eab518443521435e1d6bc10dee6778f3f96119ea6334
5
5
  SHA512:
6
- metadata.gz: b396fb4c3d553e372dab904f57cb794015b4c2670028fea33c775fc343ac844db1d0d2733a9c47cfb2450898408ad4696ba312114719f7b3d1ad55716149f674
7
- data.tar.gz: 89e9eb2e33742ec34f5c89e5e732544a868e4241ba5886cfa357e0ca43929107d895024ff6e936952c3648cf51ac379e7839ac2d211dc2515697d2ff52d8e7a7
6
+ metadata.gz: df3bff4364790fe3205baec9790c4fa123afc7945b68340afb583e56966e73acbd694f1c3016f11e9b880ba9a34bb1622620ee568970e3b625fca96da4807a09
7
+ data.tar.gz: 85bb8941d3648914ad037195c0e090b5e1df40ce3fb46b67aa6ca86a18784af3f1a0cb3ca1a9d656d54e52aead1050a2510fec3b1ccc5288e25d0f5f1f66a309
@@ -75,6 +75,7 @@ module Spree
75
75
 
76
76
  def download_and_attach_image(external_url, image)
77
77
  max_size = Spree::Config.max_image_download_size
78
+ streamed_body = nil
78
79
 
79
80
  response = SsrfFilter.get(
80
81
  external_url,
@@ -90,12 +91,12 @@ module Spree
90
91
  read_timeout: 60,
91
92
  open_timeout: 30
92
93
  }
93
- )
94
+ ) { |fetched| streamed_body = read_within(fetched, max_size) }
94
95
 
95
- body = response.body
96
- if body.bytesize > max_size
97
- raise StandardError, "Image file size exceeds the maximum allowed size of #{max_size} bytes"
98
- end
96
+ # A fetch that did not stream (a test double, say) is read whole and
97
+ # held to the same limit.
98
+ body = streamed_body || response.body
99
+ raise_too_large(max_size) if body.bytesize > max_size
99
100
 
100
101
  uri = URI.parse(external_url)
101
102
  filename = File.basename(uri.path)
@@ -114,6 +115,24 @@ module Spree
114
115
  end
115
116
  end
116
117
 
118
+ # Reads the body as it arrives and stops at the limit, so an oversized
119
+ # or endless response is refused mid-stream rather than held in memory
120
+ # first; a declared length over the limit is refused before any of it.
121
+ def read_within(response, max_size)
122
+ raise_too_large(max_size) if response['Content-Length'].to_i > max_size
123
+
124
+ buffer = +''
125
+ response.read_body do |chunk|
126
+ buffer << chunk
127
+ raise_too_large(max_size) if buffer.bytesize > max_size
128
+ end
129
+ buffer
130
+ end
131
+
132
+ def raise_too_large(max_size)
133
+ raise StandardError, "Image file size exceeds the maximum allowed size of #{max_size} bytes"
134
+ end
135
+
117
136
  def image_already_saved?(image, external_url)
118
137
  image.persisted? && image.attachment.attached? && image.external_url.present? && external_url == image.external_url
119
138
  end
@@ -0,0 +1,54 @@
1
+ module Spree
2
+ # Switches a staff account off without deleting it. Deleting a staff member
3
+ # clears them from every record that names them as its actor, so the history
4
+ # of who approved, refunded or created what is lost; deactivating keeps the
5
+ # account and refuses every way back in.
6
+ #
7
+ # Enforcement lives in the API: a deactivated account cannot sign in by any
8
+ # strategy, accept an invitation, finish a password reset or refresh a
9
+ # session, and its access tokens stop working on the next request. Including
10
+ # this concern opts a model in; the checks are duck-typed, so a custom admin
11
+ # user class without it is never refused.
12
+ #
13
+ # Secret API keys are left alone: they belong to the store, not to the person
14
+ # who created them, and revoking them would break the store's integrations.
15
+ module AccountDeactivation
16
+ # @return [Boolean]
17
+ def deactivated?
18
+ deactivated_at.present?
19
+ end
20
+
21
+ # Deactivates the account and ends every session it holds: refresh tokens
22
+ # are deleted, and OAuth tokens and grants issued to apps on its behalf are
23
+ # revoked. Access tokens already issued are refused from the next request.
24
+ #
25
+ # Safe to call again. The first deactivation time is kept, but sessions
26
+ # are ended every time, so an account whose column was set some other way
27
+ # still loses the credentials it holds.
28
+ #
29
+ # Skips validations: switching an account off must not fail because some
30
+ # unrelated attribute no longer passes today's rules.
31
+ #
32
+ # @return [void]
33
+ def deactivate!
34
+ now = Time.current
35
+ transaction do
36
+ update_columns(deactivated_at: now, updated_at: now) unless deactivated?
37
+ Spree::RefreshToken.revoke_all_for(self)
38
+ oauth_owner = { resource_owner_type: self.class.polymorphic_name, resource_owner_id: id, revoked_at: nil }
39
+ Spree::OauthAccessToken.where(oauth_owner).update_all(revoked_at: now)
40
+ Spree::OauthAccessGrant.where(oauth_owner).update_all(revoked_at: now)
41
+ end
42
+ end
43
+
44
+ # Lets the account sign in again. Sessions ended by deactivation stay
45
+ # ended; the person signs in afresh.
46
+ #
47
+ # @return [void]
48
+ def reactivate!
49
+ return unless deactivated?
50
+
51
+ update_columns(deactivated_at: nil, updated_at: Time.current)
52
+ end
53
+ end
54
+ end
@@ -6,9 +6,12 @@ module Spree
6
6
  # currency directly, so one method serves every currency it has an
7
7
  # amount for — no more method-per-currency duplication.
8
8
  #
9
- # The 5.x single `amount` + `currency` preferences keep working as the
10
- # fallback for their own currency, so upgraded stores quote unchanged
11
- # until they fill in the hash.
9
+ # Once the hash holds an amount it alone decides, and a currency it leaves
10
+ # out is not offered. The 5.x single `amount` + `currency` pair quotes its
11
+ # own currency only while the hash is empty, so upgraded stores quote
12
+ # unchanged until they fill it in. The pair's untouched default (0 in the
13
+ # store's currency) can never be read as a price someone set beside the
14
+ # hash.
12
15
  module CurrencyAmounts
13
16
  extend ActiveSupport::Concern
14
17
 
@@ -17,14 +20,15 @@ module Spree
17
20
  end
18
21
 
19
22
  # The amount this calculator quotes for a currency: the per-currency
20
- # hash first, then the legacy single amount when its currency matches.
23
+ # hash when it holds any amount, otherwise the legacy single amount for
24
+ # its own currency.
21
25
  #
22
26
  # @param currency [String]
23
27
  # @return [BigDecimal, nil] nil when the currency has no amount — the
24
28
  # method is not offered for that cart
25
29
  def amount_for(currency)
26
- explicit = preferred_amounts&.transform_keys { |key| key.to_s.upcase }&.dig(currency.to_s.upcase)
27
- return BigDecimal(explicit.to_s) if explicit.present?
30
+ amounts = currency_amounts
31
+ return amounts[currency.to_s.upcase] if amounts.any?
28
32
 
29
33
  legacy_currency = respond_to?(:preferred_currency) ? preferred_currency : nil
30
34
  return preferred_amount if legacy_currency.blank? || legacy_currency.casecmp(currency.to_s).zero?
@@ -38,6 +42,15 @@ module Spree
38
42
  def supports_currency?(currency)
39
43
  amount_for(currency).present?
40
44
  end
45
+
46
+ private
47
+
48
+ # The hash's amounts by upper-case currency, without blank entries.
49
+ def currency_amounts
50
+ (preferred_amounts || {}).each_with_object({}) do |(key, value), amounts|
51
+ amounts[key.to_s.upcase] = BigDecimal(value.to_s) if value.present?
52
+ end
53
+ end
41
54
  end
42
55
  end
43
56
  end
@@ -58,7 +58,7 @@ module Spree
58
58
  end
59
59
 
60
60
  def sibling_association_name
61
- self.class.name.demodulize.underscore.pluralize
61
+ self.class.name.demodulize.underscore.pluralize.to_sym
62
62
  end
63
63
  end
64
64
  end
@@ -43,8 +43,31 @@ module Spree
43
43
  # @return [Spree::ExternalReference, nil] nil when the reference was removed
44
44
  def set_external_id(system, external_id, metadata: nil)
45
45
  key = Spree::ExternalReference.normalize_system(system)
46
- reference = external_references.for_system(key).first
46
+ write_external_reference(key, external_references.for_system(key).first, external_id, metadata)
47
+ end
47
48
 
49
+ # Writes several references at once, leaving systems the payload does not
50
+ # mention untouched — a connector states its own key without disturbing
51
+ # another system's. Accepts the `{ system => external_id }` map the admin
52
+ # serializers render, or a list of `{ system:, external_id: }` entries.
53
+ #
54
+ # @param references [Hash{String,Symbol=>String}, Array<Hash>]
55
+ # @return [void]
56
+ def assign_external_references(references)
57
+ entries = Spree::ExternalReference.normalize_references(references)
58
+ return if entries.empty?
59
+
60
+ keys = entries.map { |entry| Spree::ExternalReference.normalize_system(entry[:system]) }
61
+ existing = external_references.where(system: keys).order(:id).group_by(&:system).transform_values(&:first)
62
+
63
+ entries.zip(keys).each do |entry, key|
64
+ existing[key] = write_external_reference(key, existing[key], entry[:external_id], entry[:metadata])
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ def write_external_reference(key, reference, external_id, metadata)
48
71
  if external_id.to_s.strip.blank?
49
72
  reference&.destroy
50
73
  external_references.reset
@@ -64,21 +87,6 @@ module Spree
64
87
  reference
65
88
  end
66
89
 
67
- # Writes several references at once, leaving systems the payload does not
68
- # mention untouched — a connector states its own key without disturbing
69
- # another system's. Accepts the `{ system => external_id }` map the admin
70
- # serializers render, or a list of `{ system:, external_id: }` entries.
71
- #
72
- # @param references [Hash{String,Symbol=>String}, Array<Hash>]
73
- # @return [void]
74
- def assign_external_references(references)
75
- Spree::ExternalReference.normalize_references(references).each do |reference|
76
- set_external_id(reference[:system], reference[:external_id], metadata: reference[:metadata])
77
- end
78
- end
79
-
80
- private
81
-
82
90
  # A reference belongs to the same store as the record it identifies.
83
91
  #
84
92
  # Store-owning models answer directly;
@@ -123,12 +123,22 @@ module Spree
123
123
 
124
124
  return values unless klass
125
125
 
126
+ # A model with a lookup of its own (Order) resolves each id through it.
127
+ unless klass.method(:find_by_param).owner == Spree::PrefixedId::ClassMethods
128
+ return values.map { |v| Spree::PrefixedId.prefixed_id?(v) ? klass.find_by_param!(v).id : v }
129
+ end
130
+
131
+ # One lookup for the whole list rather than one per id.
132
+ decoded = values.select { |v| Spree::PrefixedId.prefixed_id?(v) }.index_with { |v| klass.decode_own_prefixed_id(v) }
133
+ existing_ids = decoded.empty? ? [] : klass.where(id: decoded.values.compact).pluck(:id)
134
+
126
135
  values.map do |v|
127
- if Spree::PrefixedId.prefixed_id?(v)
128
- klass.find_by_param!(v).id
129
- else
130
- v
131
- end
136
+ next v unless decoded.key?(v)
137
+
138
+ id = decoded[v]
139
+ raise ActiveRecord::RecordNotFound.new("Couldn't find #{klass.name} with param=#{v}", klass.name) unless id && existing_ids.include?(id)
140
+
141
+ id
132
142
  end
133
143
  end
134
144
 
@@ -91,9 +91,14 @@ module Spree
91
91
  end
92
92
 
93
93
  if events.include?(:delete)
94
- before_destroy :capture_pre_destroy_payload, if: :should_publish_events?
94
+ before_destroy :capture_pre_destroy_payload, if: :lifecycle_publishing_enabled?
95
95
  after_commit :publish_delete_event, on: :destroy, if: :should_publish_events?
96
96
  end
97
+
98
+ after_save :note_lifecycle_write
99
+ after_destroy :note_lifecycle_destroy
100
+ after_commit :close_lifecycle_write
101
+ after_rollback :close_lifecycle_write
97
102
  end
98
103
 
99
104
  # A subclass publishes its lifecycle events under its own prefix, which may
@@ -308,9 +313,50 @@ module Spree
308
313
  end
309
314
 
310
315
  def should_publish_events?
316
+ !silenced_lifecycle_write? && lifecycle_publishing_enabled?
317
+ end
318
+
319
+ def lifecycle_publishing_enabled?
311
320
  self.class.publish_events && Spree::Events.lifecycle_enabled?
312
321
  end
313
322
 
323
+ # Lifecycle events publish at commit, which can come after the block that
324
+ # silenced the write ended — a default record created in its parent's
325
+ # `after_create` commits together with the parent. Each write records
326
+ # whether events were on, so a record written only while they were off
327
+ # stays silent at commit; one written with them on still publishes.
328
+ #
329
+ # The marker is read, never cleared, at commit: Rails may check several
330
+ # commit callbacks for one commit (a soft delete is also an update), and
331
+ # the order commit callbacks run in is the host app's setting. A commit or
332
+ # rollback only closes it, and the next write starts a fresh one.
333
+ def note_lifecycle_write
334
+ reopen_lifecycle_write
335
+ @_spree_lifecycle_write_enabled ||= Spree::Events.lifecycle_enabled?
336
+ end
337
+
338
+ # A destroy commits as the delete event alone, so an earlier update made
339
+ # with events on must not carry it through.
340
+ def note_lifecycle_destroy
341
+ reopen_lifecycle_write
342
+ @_spree_lifecycle_write_enabled = Spree::Events.lifecycle_enabled?
343
+ end
344
+
345
+ def silenced_lifecycle_write?
346
+ @_spree_lifecycle_write_enabled == false
347
+ end
348
+
349
+ def close_lifecycle_write
350
+ @_spree_lifecycle_write_closed = true
351
+ end
352
+
353
+ def reopen_lifecycle_write
354
+ return unless @_spree_lifecycle_write_closed
355
+
356
+ @_spree_lifecycle_write_closed = false
357
+ @_spree_lifecycle_write_enabled = nil
358
+ end
359
+
314
360
  # True when this commit was caused only by `touch` — no real save ran
315
361
  # (`after_update` fires for saves, never for touches). Flags reset every
316
362
  # commit so a later genuine update publishes normally.
@@ -62,7 +62,11 @@ module Spree
62
62
  #
63
63
  # @return [Boolean]
64
64
  def shipping_address_required?
65
- selected_methods = fulfillments.filter_map(&:delivery_method)
65
+ candidates = fulfillments.to_a
66
+ if candidates.many? && candidates.all?(ActiveRecord::Base)
67
+ ActiveRecord::Associations::Preloader.new(records: candidates, associations: { selected_delivery_rate: :delivery_method }).call
68
+ end
69
+ selected_methods = candidates.filter_map(&:delivery_method)
66
70
  return selected_methods.any?(&:requires_address?) if selected_methods.any?
67
71
 
68
72
  return false if preferred_stock_location_id.present?
@@ -270,9 +274,20 @@ module Spree
270
274
  cache[profile] = profile.present? && profile.requires_shipping_address?
271
275
  end
272
276
 
273
- # Both profile paths are preloaded: the variant's own override, and the
274
- # product's for the variants that inherit it.
275
- line_items.includes(variant: [:delivery_profile, { product: :delivery_profile }]).any? do |line_item|
277
+ # On a list the association batches across the page, so a fresh
278
+ # `includes` query would load every cart's items separately. Read
279
+ # alone, both profile paths are preloaded: the variant's own override,
280
+ # and the product's for the variants that inherit it.
281
+ profile_paths = { variant: [:delivery_profile, { product: :delivery_profile }] }
282
+ items = if !read_association_in_memory?(:line_items)
283
+ line_items.includes(profile_paths)
284
+ elsif lazy_preload_context.nil?
285
+ line_items.tap { |loaded| ActiveRecord::Associations::Preloader.new(records: loaded.to_a, associations: profile_paths).call }
286
+ else
287
+ line_items
288
+ end
289
+
290
+ items.any? do |line_item|
276
291
  profile_requires_address[line_item.variant&.resolved_delivery_profile]
277
292
  end
278
293
  end
@@ -9,7 +9,7 @@ module Spree
9
9
  if total_quantity.zero? || line_items.empty?
10
10
  false
11
11
  else
12
- line_items.includes(variant: :product).all?(&:digital?)
12
+ line_items_with_variants(variant: :product).all?(&:digital?)
13
13
  end
14
14
  end
15
15
 
@@ -18,7 +18,7 @@ module Spree
18
18
  if total_quantity.zero? || line_items.empty?
19
19
  false
20
20
  else
21
- line_items.includes(variant: :product).any?(&:digital?)
21
+ line_items_with_variants(variant: :product).any?(&:digital?)
22
22
  end
23
23
  end
24
24
 
@@ -27,7 +27,7 @@ module Spree
27
27
  if total_quantity.zero? || line_items.empty?
28
28
  false
29
29
  else
30
- line_items.includes(:variant).any?(&:with_digital_assets?)
30
+ line_items_with_variants(variant: :digital_assets).any?(&:with_digital_assets?)
31
31
  end
32
32
  end
33
33
 
@@ -55,6 +55,20 @@ module Spree
55
55
  def delivery_step_required?
56
56
  line_items.any? && !digital?
57
57
  end
58
+
59
+ private
60
+
61
+ # Lines already in memory get the associations preloaded onto them —
62
+ # onto every line of their preload context when they sit in a list, so
63
+ # the list batches them instead of querying once per purchase.
64
+ def line_items_with_variants(associations)
65
+ return line_items.includes(associations) unless read_association_in_memory?(:line_items)
66
+
67
+ loaded = line_items.to_a
68
+ records = loaded.first&.lazy_preload_context&.records || loaded
69
+ ActiveRecord::Associations::Preloader.new(records: records, associations: associations).call
70
+ loaded
71
+ end
58
72
  end
59
73
  end
60
74
  end
@@ -42,7 +42,8 @@ module Spree
42
42
  # twice. Two freight consignments are still one load to the forwarder,
43
43
  # so they are merged rather than listed.
44
44
  def build_freight_summary
45
- summaries = fulfillments.valid.filter_map { |fulfillment| fulfillment.selected_delivery_rate&.freight_summary }
45
+ shipping = read_association_in_memory?(:fulfillments) ? fulfillments.reject(&:canceled?) : fulfillments.valid
46
+ summaries = shipping.filter_map { |fulfillment| fulfillment.selected_delivery_rate&.freight_summary }
46
47
  return if summaries.empty?
47
48
 
48
49
  Spree::FreightSummary.merge(summaries)
@@ -55,7 +55,7 @@ module Spree
55
55
  # @return [Boolean]
56
56
  def confirmation_required?
57
57
  Spree::StorePreferences.read(store, :always_include_confirm_step) ||
58
- payments.valid.map(&:payment_method).compact.any?(&:confirmation_required?)
58
+ valid_payments.filter_map(&:payment_method).any?(&:confirmation_required?)
59
59
  end
60
60
 
61
61
  def unprocessed_payments
@@ -64,6 +64,12 @@ module Spree
64
64
 
65
65
  private
66
66
 
67
+ # On a list the association batches across the page, so the loaded rows
68
+ # are filtered rather than queried through `valid` once per record.
69
+ def valid_payments
70
+ read_association_in_memory?(:payments) ? payments.reject(&:has_invalid_status?) : payments.valid
71
+ end
72
+
67
73
  def process_payments_with(action)
68
74
  # Don't run if there is nothing to pay. Read from the database, not
69
75
  # the in-memory attribute: settlements land through the order status
@@ -104,6 +104,8 @@ module Spree
104
104
  # accuse a completed sale of breaking a rule written after it.
105
105
  return [] if staff_initiated? || (is_a?(Spree::Order) && completed?)
106
106
 
107
+ ActiveRecord::Associations::Preloader.new(records: line_items.to_a, associations: { variant: :product }).call
108
+
107
109
  line_items.filter_map do |line_item|
108
110
  variant = line_item.variant
109
111
  next if variant.nil?
@@ -13,7 +13,9 @@ module Spree
13
13
  def ensure_default_channel
14
14
  return if default_channel
15
15
 
16
- channels.create!(name: 'Online Store', code: Spree::Channel::DEFAULT_CODE)
16
+ Spree::Events.disable do
17
+ channels.create!(name: 'Online Store', code: Spree::Channel::DEFAULT_CODE)
18
+ end
17
19
  end
18
20
  end
19
21
  end
@@ -168,7 +168,12 @@ module Spree
168
168
  def translatable_store
169
169
  return self if is_a?(Spree::Store)
170
170
 
171
- try(:store) || Spree::Current.store
171
+ current_store = Spree::Current.store
172
+ # The request's store instance already carries its markets, so a batch
173
+ # of records does not re-read them once per record's own store copy.
174
+ return current_store if current_store && try(:store_id) == current_store.id
175
+
176
+ try(:store) || current_store
172
177
  end
173
178
 
174
179
  private
@@ -8,6 +8,7 @@ module Spree
8
8
 
9
9
  include Spree::AdminUserMethods
10
10
  include Spree::AccountLockout
11
+ include Spree::AccountDeactivation
11
12
  include Spree::PasswordPolicy
12
13
 
13
14
  validates :email, presence: true, uniqueness: { case_sensitive: false }
@@ -46,13 +46,21 @@ class Spree::Base < ApplicationRecord
46
46
  select("#{klass.table_name}.*").select(*(fields || klass::TRANSLATABLE_FIELDS))
47
47
  }
48
48
 
49
+ # The store's own rows of this model, through the association named after
50
+ # it. A model the store has no association for answers nothing: a tenant
51
+ # boundary that quietly widened to every store's rows is how a cross-store
52
+ # read ships unnoticed. Global reference data (countries, roles) and
53
+ # records reached through a parent are fetched as such, never through here.
54
+ #
55
+ # @param store [Spree::Store]
56
+ # @return [ActiveRecord::Relation]
49
57
  def self.for_store(store)
50
58
  plural_model_name = model_name.plural.gsub(/spree_/, '').to_sym
51
59
 
52
60
  if store.respond_to?(plural_model_name)
53
61
  store.send(plural_model_name)
54
62
  else
55
- self
63
+ none
56
64
  end
57
65
  end
58
66
 
@@ -81,6 +89,30 @@ class Spree::Base < ApplicationRecord
81
89
  association(name).loaded? || lazy_preload_context.present?
82
90
  end
83
91
 
92
+ # Recomputes a counter cache for many records at once. Rails'
93
+ # +reset_counters+ spends two queries per record, which a bulk write pays
94
+ # for every row it touched; this is one grouped count plus one UPDATE per
95
+ # distinct value.
96
+ #
97
+ # @param ids [Array] the records whose counter is recomputed
98
+ # @param association [Symbol] the has_many whose rows are counted
99
+ # @return [void]
100
+ def self.reset_counters_in_bulk(ids, association)
101
+ ids = Array(ids).compact.uniq
102
+ return if ids.empty?
103
+
104
+ reflection = reflect_on_association(association)
105
+ counter = reflection.klass.reflect_on_all_associations(:belongs_to).find do |belongs_to|
106
+ belongs_to.foreign_key.to_s == reflection.foreign_key.to_s && belongs_to.options[:counter_cache]
107
+ end.counter_cache_column
108
+
109
+ counts = reflection.klass.where(reflection.foreign_key => ids).group(reflection.foreign_key).count
110
+
111
+ ids.group_by { |id| counts.fetch(id, 0) }.each do |count, grouped_ids|
112
+ unscoped.where(id: grouped_ids).where.not(counter => count).update_all(counter => count)
113
+ end
114
+ end
115
+
84
116
  # @see Spree.mysql? — the single definition; this reads it so a model can
85
117
  # branch without reaching for the connection itself.
86
118
  def mysql_adapter?
@@ -3,7 +3,9 @@ module Spree
3
3
  class DigitalDelivery < ShippingCalculator
4
4
  include Spree::Calculator::CurrencyAmounts
5
5
 
6
- preference :amount, :money, default: 0
6
+ # Blank once its figure has moved into `amounts`, so the method quotes
7
+ # nothing outside the hash.
8
+ preference :amount, :money, default: 0, nullable: true
7
9
  preference :currency, :string, format: :currency, default: -> { Spree::Store.default.default_currency }
8
10
 
9
11
  def self.description
@@ -3,7 +3,9 @@ module Spree
3
3
  class FlatRate < ShippingCalculator
4
4
  include Spree::Calculator::CurrencyAmounts
5
5
 
6
- preference :amount, :money, default: 0
6
+ # Blank once its figure has moved into `amounts`, so the method quotes
7
+ # nothing outside the hash.
8
+ preference :amount, :money, default: 0, nullable: true
7
9
  # @deprecated Together with the single `amount`, superseded by the
8
10
  # per-currency `amounts` hash; kept as the fallback for its own
9
11
  # currency so upgraded stores quote unchanged.
@@ -3,7 +3,9 @@ module Spree
3
3
  class PerItem < ShippingCalculator
4
4
  include Spree::Calculator::CurrencyAmounts
5
5
 
6
- preference :amount, :money, default: 0
6
+ # Blank once its figure has moved into `amounts`, so the method quotes
7
+ # nothing outside the hash.
8
+ preference :amount, :money, default: 0, nullable: true
7
9
  preference :currency, :string, format: :currency, default: -> { Spree::Store.default.default_currency }
8
10
 
9
11
  def self.description
@@ -203,10 +203,13 @@ module Spree
203
203
  # delivery_unavailable warning per affected line item (parity with the
204
204
  # order-side ensure_available_delivery_rates).
205
205
  def prune_undeliverable_fulfillments!
206
- undeliverable = fulfillments.reload.select { |fulfillment| fulfillment.delivery_rates.empty? }
206
+ candidates = fulfillments.reload.to_a
207
+ ActiveRecord::Associations::Preloader.new(records: candidates, associations: :delivery_rates).call
208
+ undeliverable = candidates.select { |fulfillment| fulfillment.delivery_rates.empty? }
207
209
  return if undeliverable.empty?
208
210
 
209
211
  affected_line_items = undeliverable.flat_map(&:line_items).uniq
212
+ ActiveRecord::Associations::Preloader.new(records: affected_line_items, associations: { variant: :product }).call
210
213
  undeliverable.each(&:destroy)
211
214
  self.warnings |= affected_line_items.map do |line_item|
212
215
  {