spree_core 6.0.0.rc7 → 6.0.1

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 (54) hide show
  1. checksums.yaml +4 -4
  2. data/app/jobs/spree/maintenance_tasks/run_job.rb +321 -0
  3. data/app/jobs/spree/seller_transfers/execute_pending_due_job.rb +7 -5
  4. data/app/jobs/spree/seller_transfers/execute_pending_job.rb +37 -17
  5. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/backfill_order_markets.rb +48 -0
  6. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/capture_methods.rb +40 -0
  7. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/deliveries.rb +15 -0
  8. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/order_coupon_codes.rb +15 -0
  9. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/promotion_option_value_rules.rb +15 -0
  10. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/rake_step.rb +83 -0
  11. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/returns.rb +20 -0
  12. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/store_regional_settings.rb +63 -0
  13. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/users_to_customers.rb +28 -0
  14. data/app/maintenance_tasks/spree/maintenance_tasks/upgrade_step.rb +44 -0
  15. data/app/models/concerns/spree/translatable_resource.rb +19 -0
  16. data/app/models/spree/ability.rb +9 -0
  17. data/app/models/spree/invitation.rb +11 -2
  18. data/app/models/spree/maintenance_task.rb +221 -0
  19. data/app/models/spree/maintenance_task_run.rb +78 -0
  20. data/app/models/spree/order_routing/strategy/rules.rb +8 -48
  21. data/app/models/spree/seller_transfer.rb +51 -0
  22. data/app/models/spree/stock/coordinator.rb +37 -17
  23. data/app/models/spree/stock/packer.rb +19 -9
  24. data/app/models/spree/stock/prioritizer.rb +30 -15
  25. data/app/models/spree/store.rb +35 -2
  26. data/app/models/spree/upgrade_record.rb +62 -0
  27. data/app/services/spree/carts/update.rb +2 -1
  28. data/app/services/spree/maintenance_tasks/argument_parser.rb +23 -0
  29. data/app/services/spree/maintenance_tasks/cancel.rb +51 -0
  30. data/app/services/spree/maintenance_tasks/start.rb +127 -0
  31. data/app/services/spree/seeds/all.rb +22 -0
  32. data/app/services/spree/stock_reservations/reserve.rb +73 -25
  33. data/app/workflows/spree/seller_transfers/reverse.rb +5 -44
  34. data/config/locales/en.yml +1 -0
  35. data/db/migrate/20260813130001_add_capture_method_to_payment_methods.rb +1 -1
  36. data/db/migrate/20260815000001_create_spree_maintenance_task_runs.rb +40 -0
  37. data/db/migrate/20260816000001_create_spree_upgrade_records.rb +17 -0
  38. data/db/migrate/20261029100001_add_lockout_columns_to_spree_admin_users.rb +8 -0
  39. data/lib/generators/spree/api_resource/api_resource_generator.rb +2 -2
  40. data/lib/spree/checkout/default_requirements.rb +33 -5
  41. data/lib/spree/core/configuration.rb +6 -0
  42. data/lib/spree/core/engine.rb +10 -1
  43. data/lib/spree/core/permissions/default_catalog.rb +7 -0
  44. data/lib/spree/core/version.rb +1 -1
  45. data/lib/spree/core.rb +17 -1
  46. data/lib/spree/testing_support/factories/maintenance_task_run_factory.rb +7 -0
  47. data/lib/spree/time_zones.rb +34 -0
  48. data/lib/spree/upgrade.rb +189 -0
  49. data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +30 -10
  50. data/lib/tasks/maintenance_tasks.rake +42 -0
  51. data/lib/tasks/upgrade.rake +81 -60
  52. metadata +28 -6
  53. data/lib/tasks/capture_methods_migration.rake +0 -30
  54. data/lib/tasks/order_market_backfill.rake +0 -27
@@ -14,13 +14,17 @@ module Spree
14
14
 
15
15
  ApplicationRecord.transaction do
16
16
  targets = build_targets(cart)
17
- break if targets.empty?
18
17
 
19
18
  # Pessimistic lock + fresh read of count_on_hand. The lock serializes
20
19
  # concurrent checkouts and we use the locked rows below so we never
21
- # check stock against a stale association cache.
20
+ # check stock against a stale association cache. The shelves held
21
+ # now are locked with the new ones, in id order, so two checkouts
22
+ # moving holds past each other cannot deadlock.
23
+ stock_level_ids = targets.map { |_, si, _| si.id } |
24
+ Spree::StockReservation.for_order(cart).pluck(:stock_level_id)
22
25
  locked_stock_levels = Spree::StockLevel
23
- .where(id: targets.map { |_, si| si.id })
26
+ .where(id: stock_level_ids)
27
+ .order(:id)
24
28
  .lock
25
29
  .index_by(&:id)
26
30
 
@@ -29,22 +33,13 @@ module Spree
29
33
 
30
34
  this_order_used = Hash.new(0)
31
35
 
32
- targets.each do |line_item, stock_level|
36
+ reservations = targets.map do |line_item, stock_level, quantity|
33
37
  stock_level = locked_stock_levels.fetch(stock_level.id)
34
38
  available = stock_level.available_count - held.fetch(stock_level.id, 0) - this_order_used[stock_level.id]
35
39
 
36
- if available < line_item.quantity
37
- raise InsufficientStockError.new(
38
- line_item,
39
- I18n.t(
40
- 'spree.insufficient_stock_for_reservation',
41
- item: line_item.variant.name,
42
- available: [available, 0].max
43
- )
44
- )
45
- end
40
+ raise_insufficient_stock(line_item, available) if available < quantity
46
41
 
47
- this_order_used[stock_level.id] += line_item.quantity
42
+ this_order_used[stock_level.id] += quantity
48
43
 
49
44
  reservation = existing[[stock_level.id, line_item.id]] ||
50
45
  Spree::StockReservation.new(stock_level: stock_level, line_item: line_item)
@@ -55,10 +50,15 @@ module Spree
55
50
  reservation.order = cart
56
51
  reservation.cart = nil
57
52
  end
58
- reservation.quantity = line_item.quantity
53
+ reservation.quantity = quantity
59
54
  reservation.expires_at = expires_at
60
55
  reservation.save!
56
+ reservation
61
57
  end
58
+
59
+ # Left in place, a hold the allocation moved away from blocks other buyers until it expires.
60
+ moved = Spree::StockReservation.for_order(cart).where.not(id: reservations.map(&:id))
61
+ Spree::StockReservation.withdraw(moved)
62
62
  end
63
63
 
64
64
  success(cart)
@@ -68,23 +68,71 @@ module Spree
68
68
 
69
69
  private
70
70
 
71
+ # Held where the allocation packs each unit, so the hold protects the stock the order will ship.
71
72
  def build_targets(cart)
72
- cart.line_items.includes(variant: [:product, { stock_levels: :stock_location }]).filter_map do |line_item|
73
+ cart.line_items.reset
74
+ coordinator = Spree::Stock::Coordinator.new(cart)
75
+ packages = coordinator.allocated_packages
76
+ check_allocated_in_full(cart, packages_items(packages))
77
+
78
+ # Once the address is known, a unit only an origin unable to reach it
79
+ # would pack cannot ship, so it is left to the delivery check rather
80
+ # than held where it blocks buyers that origin does serve.
81
+ packages = packages.select { |package| coordinator.deliverable?(package) } if cart.ship_address.present?
82
+ items = packages_items(packages)
83
+ stock_levels = Spree::Stock::Packer.stock_levels_by_location(packages.map(&:stock_location).uniq,
84
+ cart.line_items.map(&:variant_id).uniq)
85
+
86
+ # Summed because a splitter can pack one line into several packages from one location.
87
+ quantities = Hash.new(0)
88
+ items.each do |stock_location, item|
89
+ next unless item.on_hand?
90
+
91
+ line_item = item.inventory_unit.line_item
73
92
  variant = line_item.variant
74
- next unless variant&.should_track_inventory?
75
-
76
- stock_level = select_stock_level(variant)
93
+ stock_level = stock_levels.dig(stock_location.id, variant.id)
77
94
  # Backorderable and pre-cart variants oversell past count_on_hand,
78
95
  # so a reservation capped at on-hand stock would wrongly reject them;
79
96
  # their cap is enforced by Stock::Quantifier#can_supply? instead.
97
+ next unless variant.should_track_inventory?
80
98
  next if stock_level.nil? || stock_level.backorderable? || variant.preorder?
81
99
 
82
- [line_item, stock_level]
100
+ quantities[[line_item, stock_level]] += item.quantity
101
+ end
102
+
103
+ quantities.map { |(line_item, stock_level), quantity| [line_item, stock_level, quantity] }
104
+ end
105
+
106
+ def packages_items(packages)
107
+ packages.flat_map { |package| package.contents.map { |item| [package.stock_location, item] } }
108
+ end
109
+
110
+ # A line placed nowhere is left to the delivery check, which can say why;
111
+ # one placed in part has shelves that fall short, and is refused.
112
+ def check_allocated_in_full(cart, items)
113
+ allocated = items.each_with_object(Hash.new(0)) do |(_, item), quantities|
114
+ quantities[item.inventory_unit.line_item_id] += item.quantity
115
+ end
116
+
117
+ cart.line_items.each do |line_item|
118
+ variant = line_item.variant
119
+ next unless variant&.should_track_inventory?
120
+ next if variant.preorder?
121
+
122
+ placed = allocated[line_item.id]
123
+ raise_insufficient_stock(line_item, placed) if placed.positive? && placed < line_item.quantity
83
124
  end
84
125
  end
85
126
 
86
- def select_stock_level(variant)
87
- variant.stock_levels.detect { |si| si.stock_location&.active? && si.available? }
127
+ def raise_insufficient_stock(line_item, available)
128
+ raise InsufficientStockError.new(
129
+ line_item,
130
+ I18n.t(
131
+ 'spree.insufficient_stock_for_reservation',
132
+ item: line_item.variant.name,
133
+ available: [available, 0].max
134
+ )
135
+ )
88
136
  end
89
137
 
90
138
  def held_by_others(stock_level_ids, owner)
@@ -104,8 +152,8 @@ module Spree
104
152
  def existing_reservations_for(targets)
105
153
  return {} if targets.empty?
106
154
 
107
- stock_level_ids = targets.map { |_, si| si.id }
108
- line_item_ids = targets.map { |li, _| li.id }
155
+ stock_level_ids = targets.map { |_, si, _| si.id }
156
+ line_item_ids = targets.map { |li, _, _| li.id }
109
157
 
110
158
  Spree::StockReservation
111
159
  .where(stock_level_id: stock_level_ids, line_item_id: line_item_ids)
@@ -202,34 +202,15 @@ module Spree
202
202
  kind: 'refund_reversal',
203
203
  provider: @earning.provider,
204
204
  status: 'pending'
205
- }.merge(settlement_of(bounded))
205
+ }.merge(@earning.reversal_settlement(bounded))
206
206
  )
207
207
  end
208
208
  end
209
209
 
210
- # A clawback has to settle where its earning settled. Payouts are swept by
211
- # settlement currency, so a reversal left in the sale's currency would
212
- # never join the batch that pays the earning it cancels — the seller would
213
- # be paid in full for goods that came back, and the row would sit in a
214
- # currency their account cannot pay.
215
- #
216
- # Prorated from the earning's own settled figure rather than converted at
217
- # today's rate, so the money comes back at the rate it went out at.
218
- def settlement_of(bounded)
219
- return {} if @earning.settled_amount.blank? || @earning.amount.zero?
220
-
221
- share = @earning.settled_amount * (bounded / @earning.amount)
222
-
223
- {
224
- settled_amount: -Spree::Money::Rounding.quantize(
225
- share, Spree::Money::Rounding.precision(@earning.settlement_currency)
226
- ),
227
- settled_currency: @earning.settled_currency
228
- }
229
- end
230
-
210
+ # Asked of the provider that made the earning — see
211
+ # {Spree::SellerTransfer#payout_provider_instance}.
231
212
  def execute_reversal
232
- provider.reverse!(reversal)
213
+ @earning.payout_provider_instance.reverse!(reversal)
233
214
  rescue Spree::Core::AmbiguousGatewayError => e
234
215
  # Whether the clawback happened is the provider's to say. Recorded as
235
216
  # such rather than as a refusal, so an operator reconciling knows which
@@ -238,31 +219,11 @@ module Spree
238
219
  Rails.error.report(e, handled: true, context: { seller_transfer_id: reversal.id }, source: 'spree.core')
239
220
  failure(reversal, e.message)
240
221
  rescue StandardError => e
222
+ # Left retryable: `ExecutePendingJob` asks the provider again.
241
223
  reversal.update!(status: 'processing')
242
224
  Rails.error.report(e, handled: true, context: { seller_transfer_id: reversal.id }, source: 'spree.core')
243
225
  failure(reversal, e.message)
244
226
  end
245
-
246
- # The provider that made the earning, not whichever one the store uses
247
- # now. A marketplace that changes provider still has money sitting with
248
- # the old one, and asking the new one to reverse a transfer it never
249
- # made leaves the original standing — the seller keeps a refunded sale.
250
- def provider
251
- @provider ||= begin
252
- configured = Spree.payout_providers.find { |candidate| candidate.to_s == @earning.provider }
253
-
254
- # No silent fallback to whatever the store uses now. A provider that
255
- # is no longer installed still holds the transfer this reverses, and
256
- # handing the job to a different one would mark the row reversed
257
- # while the money stayed where it was. An operator has to know.
258
- if configured.nil?
259
- raise Spree::Core::GatewayError,
260
- "Payout provider #{@earning.provider} is not registered, so its transfer cannot be reversed"
261
- end
262
-
263
- configured.new
264
- end
265
- end
266
227
  end
267
228
  end
268
229
  end
@@ -1032,6 +1032,7 @@ en:
1032
1032
  locale_not_supported_by_market: is not offered by the chosen market
1033
1033
  locale_not_supported_by_store: is not supported by this store
1034
1034
  locales_not_listed: 'includes locales Spree does not support: %{codes}'
1035
+ maintenance_tasks_turned_off: Maintenance tasks are turned off for this installation. Run upgrade steps from the command line with `spree upgrade` (or `bin/rake spree:upgrade`).
1035
1036
  manual: Manual
1036
1037
  market: Market
1037
1038
  me_no_current_user: No admin user authenticated this request
@@ -10,7 +10,7 @@ class AddCaptureMethodToPaymentMethods < ActiveRecord::Migration[8.1]
10
10
  # from the provider configuration form.
11
11
  #
12
12
  # Existing auto_capture values are carried over by
13
- # spree:migrate_capture_methods, never here.
13
+ # the capture_methods maintenance task, never here.
14
14
  def change
15
15
  add_column :spree_payment_methods, :capture_method, :string
16
16
  end
@@ -0,0 +1,40 @@
1
+ class CreateSpreeMaintenanceTaskRuns < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :spree_maintenance_task_runs do |t|
4
+ t.string :task_name, null: false
5
+ t.string :status, null: false
6
+ t.boolean :dry_run, null: false, default: false
7
+
8
+ t.string :cursor
9
+ t.integer :tick_count, null: false, default: 0
10
+ t.integer :tick_total
11
+
12
+ t.decimal :time_running, null: false, default: 0.0, precision: 12, scale: 3
13
+ t.datetime :started_at
14
+ t.datetime :ended_at
15
+
16
+ t.string :error_class
17
+ t.text :error_message
18
+ t.text :error_backtrace
19
+
20
+ t.string :job_id
21
+ t.string :initiated_via, null: false
22
+
23
+ t.references :admin_user, null: true
24
+ t.references :api_key, null: true
25
+
26
+ if t.respond_to?(:jsonb)
27
+ t.jsonb :arguments
28
+ t.jsonb :tallies
29
+ else
30
+ t.json :arguments
31
+ t.json :tallies
32
+ end
33
+
34
+ t.timestamps
35
+ end
36
+
37
+ add_index :spree_maintenance_task_runs, [:task_name, :status]
38
+ add_index :spree_maintenance_task_runs, :created_at
39
+ end
40
+ end
@@ -0,0 +1,17 @@
1
+ class CreateSpreeUpgradeRecords < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :spree_upgrade_records do |t|
4
+ # The release boundary this installation has completed the data steps
5
+ # for — "5.6" means every manifest up to and including 5.5 → 5.6 is done.
6
+ t.string :version, null: false
7
+ # How we know: `walk` (a successful upgrade walk), `install` (a fresh
8
+ # install, which needs no historical steps) or `manual`.
9
+ t.string :source, null: false
10
+ t.datetime :completed_at, null: false
11
+
12
+ t.timestamps
13
+ end
14
+
15
+ add_index :spree_upgrade_records, :version, unique: true
16
+ end
17
+ end
@@ -0,0 +1,8 @@
1
+ # Spree::AccountLockout reads these columns. Installs upgraded from a Devise
2
+ # admin model without :lockable reach 6.0 with neither of them.
3
+ class AddLockoutColumnsToSpreeAdminUsers < ActiveRecord::Migration[8.1]
4
+ def change
5
+ add_column :spree_admin_users, :failed_attempts, :integer unless column_exists?(:spree_admin_users, :failed_attempts)
6
+ add_column :spree_admin_users, :locked_at, :datetime unless column_exists?(:spree_admin_users, :locked_at)
7
+ end
8
+ end
@@ -108,7 +108,7 @@ module Spree
108
108
 
109
109
  # Override parent: skip if the model existed before this run. Migrations
110
110
  # are append-only — schema changes get a separate migration:
111
- # pnpm exec spree rails g migration AddFooToBar foo:string
111
+ # pnpm exec spree generate migration AddFooToBar foo:string
112
112
  def create_migration_file
113
113
  if @model_existed_before_run
114
114
  say_status :skip, 'migration (model already exists; add a new migration for schema changes)', :yellow
@@ -295,7 +295,7 @@ module Spree
295
295
  end
296
296
  say " 4. Add `has_many :#{plural_name}` to Spree::Store" if store_scoped?
297
297
  if options[:store] || options[:admin]
298
- say " #{store_scoped? ? 5 : 4}. Run the specs: pnpm exec spree rspec spec/controllers/spree/api/v3/"
298
+ say " #{store_scoped? ? 5 : 4}. Run the specs: pnpm exec spree test spec/controllers/spree/api/v3/"
299
299
  end
300
300
  say ''
301
301
  end
@@ -10,6 +10,12 @@ module Spree
10
10
  #
11
11
  # @see Requirements
12
12
  class DefaultRequirements
13
+ # Everything the discontinuation and stock checks read, so a set of
14
+ # lines is checked at once.
15
+ STOCK_CHECK_ASSOCIATIONS = {
16
+ variant: [{ product: %i[store product_publications] }, { stock_levels: %i[stock_location active_stock_reservations] }]
17
+ }.freeze
18
+
13
19
  # @param cart [Spree::Cart]
14
20
  def initialize(cart)
15
21
  @cart = cart
@@ -33,6 +39,7 @@ module Spree
33
39
  r << req('address', 'email', I18n.t('spree.checkout_requirements.email_required')) unless @cart.email.present?
34
40
  r << req('address', 'ship_address', I18n.t('spree.checkout_requirements.ship_address_required')) if @cart.shipping_address_required? && @cart.ship_address.blank?
35
41
  r << req('delivery', 'delivery_method', I18n.t('spree.checkout_requirements.delivery_method_required')) if delivery_step_required? && !delivery_method_selected?
42
+ r.concat(undeliverable_errors)
36
43
  r << req('payment', 'payment', I18n.t('spree.checkout_requirements.payment_required')) if payment_required? && !payment_satisfied?
37
44
  r << req('address', 'po_number', I18n.t('spree.checkout_requirements.po_number_required')) if po_number_missing?
38
45
  r << order_minimum_requirement if below_order_minimum?
@@ -102,16 +109,37 @@ module Spree
102
109
  end
103
110
  end
104
111
 
112
+ # A rebuild drops a proposal no method can quote with only a transient
113
+ # warning, so part of a line can be in no fulfillment. Advisory, so the
114
+ # buyer reads it while they can still change the address or the cart.
115
+ def undeliverable_errors
116
+ return [] unless delivery_step_required? && @cart.fulfillments.any?
117
+
118
+ undelivered_line_items.map do |line_item|
119
+ req('delivery', 'line_items', I18n.t('spree.cart_line_item.delivery_unavailable', li_name: line_item.name),
120
+ code: 'delivery_unavailable')
121
+ end
122
+ end
123
+
124
+ # A line short of stock is packed short too; the stock check already
125
+ # names it. Only lines short of a fulfillment pay for that check, so a
126
+ # cart read stays at one query.
127
+ def undelivered_line_items
128
+ allocated = Spree::FulfillmentItem.where(fulfillment_id: @cart.fulfillments.select(:id))
129
+ .group(:line_item_id).sum(:quantity)
130
+ short = @cart.line_items.select { |line_item| allocated.fetch(line_item.id, 0) < line_item.quantity }
131
+ return [] if short.empty?
132
+
133
+ ActiveRecord::Associations::Preloader.new(records: short, associations: STOCK_CHECK_ASSOCIATIONS).call
134
+ short.reject { |line_item| discontinued?(line_item) || !line_item.sufficient_stock? }
135
+ end
136
+
105
137
  def discontinued?(line_item)
106
138
  line_item.variant.nil? || line_item.variant.discontinued? || line_item.variant.product.discontinued?
107
139
  end
108
140
 
109
141
  def completion_line_items
110
- # Everything the discontinuation and stock checks read, for every line
111
- # at once.
112
- @completion_line_items ||= @cart.line_items.includes(
113
- variant: [{ product: %i[store product_publications] }, { stock_levels: %i[stock_location active_stock_reservations] }]
114
- ).to_a
142
+ @completion_line_items ||= @cart.line_items.includes(STOCK_CHECK_ASSOCIATIONS).to_a
115
143
  end
116
144
 
117
145
  def delivery_step_required?
@@ -195,6 +195,12 @@ module Spree
195
195
  # imports
196
196
  preference :large_import_threshold, :integer, default: 500, env: 'SPREE_LARGE_IMPORT_THRESHOLD' # imports with more rows than this skip per-row UI broadcasts and use bulk processing
197
197
 
198
+ # maintenance tasks
199
+ # Turn off to run upgrade steps only from the command line: the dashboard
200
+ # page and its Admin API endpoints are refused. For hosts where the people
201
+ # holding a store's admin role are not the ones operating the installation,
202
+ # such as a platform serving many merchants from one database.
203
+ preference :maintenance_tasks_dashboard_enabled, :boolean, default: true, env: 'SPREE_MAINTENANCE_TASKS_DASHBOARD_ENABLED'
198
204
  end
199
205
  end
200
206
  end
@@ -46,6 +46,7 @@ module Spree
46
46
  :admin_agent_tools,
47
47
  :store_agent_tools,
48
48
  :integrations,
49
+ :maintenance_tasks,
49
50
  :number_generators,
50
51
  :subscribers,
51
52
  :store_authentication_strategies,
@@ -77,7 +78,8 @@ module Spree
77
78
  # Core's defaults are concatenated in after_initialize below.
78
79
  %i[subscribers actor_classes commission_rules delivery_method_rules seller_requirements
79
80
  delivery_rate_providers tax_providers pricing_providers inventory_providers
80
- payout_providers digital_asset_providers delivery_profile_types pickup_point_providers].each do |registry|
81
+ payout_providers digital_asset_providers delivery_profile_types pickup_point_providers
82
+ maintenance_tasks].each do |registry|
81
83
  app.config.spree[registry] = []
82
84
  end
83
85
  app.config.spree.line_item_comparison_hooks = Set.new
@@ -540,6 +542,13 @@ module Spree
540
542
 
541
543
  Rails.application.config.spree.integrations = []
542
544
 
545
+ # Upgrade steps backed by a task class are registered from the manifests
546
+ # that name them, so adding a step never means editing a second list.
547
+ Rails.application.config.spree.maintenance_tasks.concat [
548
+ 'Spree::MaintenanceTasks::UpgradeStep',
549
+ *Spree::Upgrade.steps.filter_map { |step| step['task_class'] }
550
+ ]
551
+
543
552
  Rails.application.config.spree.validators.addresses = [
544
553
  Spree::Addresses::PhoneValidator
545
554
  ]
@@ -201,6 +201,13 @@ module Spree
201
201
  })
202
202
  catalog.register_scope(:integrations, group: :settings, resources: -> { [Spree::Integration] })
203
203
 
204
+ # Sits with the other sensitive access grants rather than under
205
+ # settings: a task can rewrite any data in the install, so out of the
206
+ # box only the admin role holds it (docs/plans/6.0-maintenance-tasks.md).
207
+ catalog.register_scope(:maintenance_tasks, group: :access, resources: -> {
208
+ [Spree::MaintenanceTaskRun, :maintenance_task]
209
+ })
210
+
204
211
  # The CORS allowlist is a credential as much as a key is: an origin on
205
212
  # it may call the cookie-authenticated refresh endpoint from a browser
206
213
  # and read back any signed-in admin's access token. So it sits with
@@ -1,5 +1,5 @@
1
1
  module Spree
2
- VERSION = '6.0.0.rc7'.freeze
2
+ VERSION = '6.0.1'.freeze
3
3
 
4
4
  def self.version
5
5
  VERSION
data/lib/spree/core.rb CHANGED
@@ -135,7 +135,8 @@ module Spree
135
135
  stock_reservations: :default,
136
136
  tax_identifiers: :default,
137
137
  data_requests: :default,
138
- payouts: :default
138
+ payouts: :default,
139
+ maintenance_tasks: :default
139
140
  ).tap do |queues|
140
141
  # @deprecated The taxons queue was renamed to categories in 6.0; removed in 6.1.
141
142
  queues.define_singleton_method(:taxons) do
@@ -455,6 +456,19 @@ module Spree
455
456
  custom_fields
456
457
  end
457
458
 
459
+ # Registered maintenance task class names — the tasks an operator can run
460
+ # from the dashboard, the Admin API or the CLI
461
+ # (docs/plans/6.0-maintenance-tasks.md). Extensions append their own from an
462
+ # initializer:
463
+ #
464
+ # Spree.maintenance_tasks << 'SpreeReviews::MaintenanceTasks::RecountRatings'
465
+ #
466
+ # Class names rather than classes, so registration is safe at initializer
467
+ # time and survives code reloading in development.
468
+ #
469
+ # @return [Array<String, Class>]
470
+ singleton_class.delegate :maintenance_tasks, :maintenance_tasks=, to: :spree_config
471
+
458
472
  # Registry mapping a numbered resource to the generator that produces its
459
473
  # document numbers. With no entry, the store's `document_number_format`
460
474
  # preference picks between the sequential and random strategies.
@@ -644,6 +658,7 @@ require 'spree/core/version'
644
658
  require 'spree/core/number_generator'
645
659
  require 'spree/number_generators/registry'
646
660
  require 'spree/migrations'
661
+ require 'spree/upgrade'
647
662
  require 'spree/validators'
648
663
  require 'spree/core/engine'
649
664
 
@@ -651,6 +666,7 @@ require 'spree/i18n'
651
666
  require 'spree/iso_data'
652
667
  require 'spree/localized_number'
653
668
  require 'spree/measurement'
669
+ require 'spree/time_zones'
654
670
  require 'spree/translations'
655
671
  require 'spree/money'
656
672
  require 'spree/service_module'
@@ -0,0 +1,7 @@
1
+ FactoryBot.define do
2
+ factory :maintenance_task_run, class: 'Spree::MaintenanceTaskRun' do
3
+ task_name { 'Spree::MaintenanceTasks::BackfillOrderMarkets' }
4
+ status { 'enqueued' }
5
+ initiated_via { 'api' }
6
+ end
7
+ end
@@ -0,0 +1,34 @@
1
+ module Spree
2
+ # Time zones are stored and exchanged as IANA identifiers (`Europe/Berlin`),
3
+ # the names browsers and every non-Ruby client understand. Rails' display
4
+ # names ("Eastern Time (US & Canada)") are converted to them on the way in.
5
+ module TimeZones
6
+ # The zones clients are offered: the IANA zones Rails gives a display name
7
+ # to, with plain `UTC` standing in for `Etc/UTC`. Any other valid IANA
8
+ # identifier is accepted too. The list comes with the Rails version rather
9
+ # than the machine's time zone database, so generated types match wherever
10
+ # the same Rails version runs.
11
+ SUGGESTED = (ActiveSupport::TimeZone::MAPPING.values - ['Etc/UTC'] + ['UTC']).uniq.sort.freeze
12
+
13
+ # @param identifier [String, nil]
14
+ # @return [Boolean] whether the value is an IANA time zone identifier
15
+ def self.valid?(identifier)
16
+ return false if identifier.blank?
17
+
18
+ TZInfo::Timezone.get(identifier.to_s)
19
+ true
20
+ rescue TZInfo::InvalidTimezoneIdentifier
21
+ false
22
+ end
23
+
24
+ # @param value [String, nil] an IANA identifier or a Rails time zone name
25
+ # @return [String, nil] the IANA identifier, or the value unchanged when it
26
+ # names no zone the installed time zone database knows
27
+ def self.normalize(value)
28
+ return value if value.blank? || valid?(value)
29
+
30
+ identifier = ActiveSupport::TimeZone::MAPPING[value.to_s]
31
+ valid?(identifier) ? identifier : value
32
+ end
33
+ end
34
+ end