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
@@ -22,13 +22,45 @@ module Spree
22
22
  end
23
23
 
24
24
  def packages
25
- packages = build_packages
26
- packages = prioritize_packages(packages)
27
- packages = estimate_packages(packages)
25
+ estimate_packages(allocated_packages)
28
26
  end
29
27
 
30
- def build_packages(packages = [])
31
- stock_locations = stock_locations_with_requested_variants.to_a
28
+ # Where each unit comes from, without quoting a rate, so a caller that
29
+ # only needs the origins — a checkout hold — never costs a carrier request.
30
+ #
31
+ # @param stock_locations [Array<Spree::StockLocation>] candidate origins, preferred first
32
+ # @return [Array<Spree::Stock::Package>]
33
+ def allocated_packages(stock_locations = candidate_stock_locations)
34
+ prioritize_packages(build_packages(stock_locations: stock_locations))
35
+ end
36
+
37
+ # Whether any delivery method can carry the package to the order's
38
+ # address, by the rule allocation ranked origins with, so no rate is requested.
39
+ #
40
+ # @param package [Spree::Stock::Package]
41
+ # @return [Boolean]
42
+ def deliverable?(package)
43
+ estimator.deliverable?(package)
44
+ end
45
+
46
+ # @param packages [Array<Spree::Stock::Package>]
47
+ # @return [Array<Spree::Stock::Package>] the same packages, with their delivery rates
48
+ def estimate_packages(packages)
49
+ packages.each do |package|
50
+ package.delivery_rates = estimator.delivery_rates(package)
51
+ end
52
+ packages
53
+ end
54
+
55
+ # Active locations stocking any requested variant, in database order.
56
+ #
57
+ # @return [Array<Spree::StockLocation>]
58
+ def candidate_stock_locations
59
+ order.store.stock_locations.active.joins(:stock_levels)
60
+ .where(Spree::StockLevel.table_name => { variant_id: requested_variant_ids }).distinct.to_a
61
+ end
62
+
63
+ def build_packages(packages = [], stock_locations: candidate_stock_locations)
32
64
  stock_levels = Packer.stock_levels_by_location(stock_locations, requested_variant_ids)
33
65
 
34
66
  stock_locations.each do |stock_location|
@@ -44,11 +76,6 @@ module Spree
44
76
 
45
77
  private
46
78
 
47
- def stock_locations_with_requested_variants
48
- order.store.stock_locations.active.joins(:stock_levels).
49
- where(Spree::StockLevel.table_name => { variant_id: requested_variant_ids }).distinct
50
- end
51
-
52
79
  # An item may only be allocated from locations its delivery profile
53
80
  # covers — a profile narrowed to the cold-storage warehouse never packs
54
81
  # from anywhere else — intersected with the channel's served set when
@@ -104,13 +131,6 @@ module Spree
104
131
  prioritizer.prioritized_packages
105
132
  end
106
133
 
107
- def estimate_packages(packages)
108
- packages.each do |package|
109
- package.delivery_rates = estimator.delivery_rates(package)
110
- end
111
- packages
112
- end
113
-
114
134
  # Shared between prioritization (which origins can deliver at all) and
115
135
  # the rate refresh, so both answer with the same eligibility rules.
116
136
  def estimator
@@ -44,25 +44,35 @@ module Spree
44
44
  package = build_package
45
45
 
46
46
  # Group by variant_id as grouping by variant fires cached query.
47
- units_by_variant_id = inventory_units.index_by(&:variant_id)
47
+ units_by_variant_id = inventory_units.group_by(&:variant_id)
48
48
  # The package's variants and their levels here, each in one query
49
49
  # rather than one per variant. Lowest id first, as StockLocation#stock_level reads.
50
50
  variants = variants_for(units_by_variant_id)
51
51
  stock_levels = @stock_levels || stock_location.stock_levels.where(variant_id: units_by_variant_id.keys).order(:id).
52
52
  group_by(&:variant_id).transform_values(&:first)
53
53
 
54
- units_by_variant_id.each do |variant_id, inventory_unit|
54
+ units_by_variant_id.each do |variant_id, units|
55
55
  variant = variants[variant_id] || Spree::Variant.find(variant_id)
56
- unit = inventory_unit.dup # Can be used by others, do not use directly
56
+ # Can be used by others, do not use directly
57
+ units = units.map(&:dup)
57
58
  if variant.should_track_inventory?
58
59
  stock_level = stock_levels[variant_id]
59
60
  next if stock_level.nil?
60
61
 
61
- on_hand, backordered = stock_location.fill_status(variant, unit.quantity, stock_level: stock_level)
62
- package.add(FulfillmentItem.split(unit, backordered), :backordered) if backordered.positive?
63
- package.add(FulfillmentItem.split(unit, on_hand), :on_hand) if on_hand.positive?
62
+ on_hand, backordered = stock_location.fill_status(variant, units.sum(&:quantity), stock_level: stock_level)
63
+ # Two lines of one variant draw on one shelf, so it is split
64
+ # between them in order rather than offered to each in full.
65
+ units.each do |unit|
66
+ unit_on_hand = [on_hand, unit.quantity].min
67
+ unit_backordered = [backordered, unit.quantity - unit_on_hand].min
68
+ on_hand -= unit_on_hand
69
+ backordered -= unit_backordered
70
+
71
+ package.add(FulfillmentItem.split(unit, unit_backordered), :backordered) if unit_backordered.positive?
72
+ package.add(FulfillmentItem.split(unit, unit_on_hand), :on_hand) if unit_on_hand.positive?
73
+ end
64
74
  else
65
- package.add unit
75
+ units.each { |unit| package.add unit }
66
76
  end
67
77
  end
68
78
 
@@ -84,8 +94,8 @@ module Spree
84
94
  # The units' own variants when they carry them, so a cart's variants are
85
95
  # read once however many locations are packed.
86
96
  def variants_for(units_by_variant_id)
87
- variants = units_by_variant_id.each_with_object({}) do |(variant_id, unit), loaded|
88
- loaded[variant_id] = unit.variant if unit.association(:variant).loaded?
97
+ variants = units_by_variant_id.each_with_object({}) do |(variant_id, units), loaded|
98
+ loaded[variant_id] = units.first.variant if units.first.association(:variant).loaded?
89
99
  end
90
100
  missing_ids = units_by_variant_id.keys - variants.keys
91
101
  missing_ids.empty? ? variants : variants.merge(Spree::Variant.where(id: missing_ids).index_by(&:id))
@@ -18,6 +18,7 @@ module Spree
18
18
 
19
19
  def prioritized_packages
20
20
  sort_packages
21
+ withdraw_covered_items
21
22
  adjust_packages
22
23
  prune_packages
23
24
  packages
@@ -44,11 +45,10 @@ module Spree
44
45
  end
45
46
 
46
47
  # Origins that can deliver go first, so the Adjuster fills each unit from
47
- # one of them and the origins that cannot are left empty and pruned. A
48
- # profile with per-region origin groups would otherwise allocate to
49
- # whichever warehouse came first, and when that warehouse's group has no
50
- # method for the destination the item is dropped as undeliverable even
51
- # though another warehouse could have shipped it.
48
+ # one of them. A profile with per-region origin groups would otherwise
49
+ # allocate to whichever warehouse came first, and when that warehouse's
50
+ # group has no method for the destination the item is dropped as
51
+ # undeliverable even though another warehouse could have shipped it.
52
52
  #
53
53
  # The caller's order survives within each group, so routing rank and the
54
54
  # store's location order still decide between origins that can both
@@ -56,25 +56,40 @@ module Spree
56
56
  def sort_packages
57
57
  return if estimator.nil?
58
58
 
59
- ranked = packages.each_with_index.sort_by do |package, index|
60
- [estimator.deliverable?(package) ? 0 : 1, index]
59
+ deliverable, @undeliverable_packages = packages.partition { |package| estimator.deliverable?(package) }
60
+ packages.replace(deliverable + @undeliverable_packages)
61
+ end
62
+
63
+ # Ordering alone loses to the Adjuster, which takes stock on hand anywhere
64
+ # over a backorder; a unit the capable origins cannot cover stays, so it is
65
+ # still reported as undeliverable rather than lost.
66
+ def withdraw_covered_items
67
+ return if @undeliverable_packages.blank? || @undeliverable_packages.size == packages.size
68
+
69
+ supplied = supplied_quantities(packages - @undeliverable_packages)
70
+
71
+ @undeliverable_packages.each do |package|
72
+ package.contents.each do |item|
73
+ package.remove_item(item) if supplied[hash_item(item)] >= item.inventory_unit.required_quantity
74
+ end
61
75
  end
76
+ end
62
77
 
63
- packages.replace(ranked.map(&:first))
78
+ def supplied_quantities(packages)
79
+ packages.flat_map(&:contents).each_with_object(Hash.new(0)) do |item, supplied|
80
+ supplied[hash_item(item)] += item.quantity
81
+ end
64
82
  end
65
83
 
66
84
  def prune_packages
67
85
  packages.reject!(&:empty?)
68
86
  end
69
87
 
88
+ # A proposed unit has no fulfillment, so its line item keeps two lines of
89
+ # one variant apart; a pair, as XORed ids collide whenever they match.
70
90
  def hash_item(item)
71
- shipment = item.inventory_unit.fulfillment
72
- variant = item.inventory_unit.variant
73
- if shipment.present?
74
- variant.hash ^ shipment.hash
75
- else
76
- variant.hash
77
- end
91
+ unit = item.inventory_unit
92
+ [unit.variant, unit.fulfillment || unit.line_item_id]
78
93
  end
79
94
  end
80
95
  end
@@ -42,6 +42,11 @@ module Spree
42
42
  acts_as_paranoid
43
43
  # deleted translation values still need to be accessible - remove deleted_at scope
44
44
  default_scope { unscope(where: :deleted_at) }
45
+
46
+ # Mobility deletes a row once its last value is cleared. The soft delete
47
+ # stamps only `deleted_at`, and the row stays readable, so the cleared
48
+ # values have to be written too or the old translation keeps showing.
49
+ before_destroy { update_columns(changes_to_save.transform_values(&:last)) if has_changes_to_save? }
45
50
  end
46
51
 
47
52
  #
@@ -50,8 +55,9 @@ module Spree
50
55
  UNIT_SYSTEMS = %w[metric imperial].freeze
51
56
 
52
57
  # general preferences
53
- preference :admin_locale, :string, format: :locale
54
- preference :timezone, :string, format: :timezone, default: Time.zone.name
58
+ preference :admin_locale, :string, format: :locale, parse_on_set: ->(value) { Spree::Locales.listed(value) || value.presence }
59
+ preference :timezone, :string, format: :timezone, default: Spree::TimeZones.normalize(Time.zone.name),
60
+ parse_on_set: ->(value) { Spree::TimeZones.normalize(value) }
55
61
  preference :weight_unit, :string, default: 'lb', choices: -> { Spree::Variant::WEIGHT_UNITS }
56
62
  preference :unit_system, :string, default: 'imperial', choices: -> { UNIT_SYSTEMS }
57
63
  # Anonymous identifier of this installation, kept on the default store
@@ -403,6 +409,8 @@ module Spree
403
409
  message: :invalid_document_number_affix }
404
410
  validates :preferred_capture_method, inclusion: { in: Spree::CaptureMethod::CAPTURE_METHODS }
405
411
  validate :preferred_storefront_url_is_an_origin
412
+ validate :preferred_admin_locale_is_listed
413
+ validate :preferred_timezone_is_a_time_zone
406
414
  validate :order_number_sequence_start_unchanged_after_first_number, on: :update
407
415
  validates :mail_from_address, email: { allow_blank: false }
408
416
  validates :customer_support_email, email: { allow_blank: true }
@@ -836,6 +844,31 @@ module Spree
836
844
  errors.add(:preferred_storefront_url, :invalid)
837
845
  end
838
846
 
847
+ def preferred_admin_locale_is_listed
848
+ locale = preferred_admin_locale
849
+ return if locale.blank? || Spree::Locales::ALL.include?(locale)
850
+ return if awaiting_upgrade?(:admin_locale) { Spree::Locales.listed(locale) }
851
+
852
+ errors.add(:preferred_admin_locale, :inclusion)
853
+ end
854
+
855
+ def preferred_timezone_is_a_time_zone
856
+ return if Spree::TimeZones.valid?(preferred_timezone)
857
+ return if awaiting_upgrade?(:timezone) { Spree::TimeZones.valid?(Spree::TimeZones.normalize(preferred_timezone)) }
858
+
859
+ errors.add(:preferred_timezone, :invalid)
860
+ end
861
+
862
+ # A value saved before 6.0 that the store settings upgrade step converts
863
+ # must not block saving the store's other settings until the step runs.
864
+ # The block says whether the step can convert it.
865
+ def awaiting_upgrade?(name)
866
+ return false unless persisted?
867
+ return false unless public_send(:"preferred_#{name}") == (attribute_was('preferences') || {})[name]
868
+
869
+ yield.present?
870
+ end
871
+
839
872
  # The starting value only shapes a counter that does not exist yet — once
840
873
  # the first number is issued the counter owns the value, so accepting a
841
874
  # change here would persist a setting that can never apply again. The
@@ -0,0 +1,62 @@
1
+ module Spree
2
+ # A release boundary whose data steps this installation has completed
3
+ # (docs/plans/6.0-maintenance-tasks.md).
4
+ #
5
+ # This exists because nothing else in the schema records where an
6
+ # installation upgraded *from*. `Spree.version` is only ever the gem that is
7
+ # installed now, so without a stamp the dashboard cannot tell a store that
8
+ # has been on 5.6 for months from one jumping straight from 5.4 — and would
9
+ # have to show both every step of every manifest, which grows with each
10
+ # release.
11
+ #
12
+ # Written from three places, none of which guesses:
13
+ #
14
+ # - a successful `spree:upgrade` walk stamps the boundary it reached
15
+ # - a fresh install stamps the current version, because a database created
16
+ # by today's schema has no historical data to convert
17
+ # - an operator can stamp one by hand, for a store upgraded before this
18
+ # record existed
19
+ class UpgradeRecord < Spree.base_class
20
+ has_prefix_id :upg
21
+
22
+ SOURCES = %w[walk install manual].freeze
23
+
24
+ validates :version, presence: true, uniqueness: true
25
+ validates :source, presence: true, inclusion: { in: SOURCES }
26
+ validates :completed_at, presence: true
27
+
28
+ # Whether this installation has ever been upgraded, as opposed to having
29
+ # been installed at its current version.
30
+ #
31
+ # A store installed fresh at 6.0 has no historical data to convert and no
32
+ # upgrade to perform — showing it a manifest of steps, current or past,
33
+ # describes work that does not exist for it.
34
+ #
35
+ # @return [Boolean]
36
+ def self.upgraded?
37
+ where.not(source: 'install').exists?
38
+ end
39
+
40
+ # The highest boundary recorded, or nil when this installation has never
41
+ # stamped one.
42
+ #
43
+ # @return [String, nil]
44
+ def self.current_version
45
+ all.map(&:version).max_by { |version| Spree::Upgrade.version_parts(version) }
46
+ end
47
+
48
+ # Records a completed boundary. Idempotent — re-running a walk that has
49
+ # already been stamped updates when it happened rather than failing.
50
+ #
51
+ # @param version [String] two-segment release, e.g. "6.0"
52
+ # @param source [String] one of SOURCES
53
+ # @return [Spree::UpgradeRecord]
54
+ def self.stamp!(version, source: 'walk')
55
+ record = find_or_initialize_by(version: version.to_s)
56
+ record.source = source.to_s
57
+ record.completed_at = Time.current
58
+ record.save!
59
+ record
60
+ end
61
+ end
62
+ end
@@ -252,12 +252,13 @@ module Spree
252
252
 
253
253
  # Three-way dispatch on the cart→checkout transition:
254
254
  # entering checkout → Reserve, mid-checkout mutation → Extend, reverting to cart → Release.
255
+ # A new destination reserves again, because it can move the units to another origin.
255
256
  # A failed Reserve raises so the enclosing transaction rolls back and the
256
257
  # outer rescue surfaces the error to the API caller.
257
258
  def sync_stock_reservations(was_in_checkout:)
258
259
  if !cart.in_checkout?
259
260
  Spree::StockReservations::Release.call(cart: cart) if was_in_checkout
260
- elsif was_in_checkout
261
+ elsif was_in_checkout && !@destination_changed && !@new_delivery_destination
261
262
  Spree::StockReservations::Extend.call(cart: cart)
262
263
  else
263
264
  result = Spree::StockReservations::Reserve.call(cart: cart)
@@ -0,0 +1,23 @@
1
+ module Spree
2
+ module MaintenanceTasks
3
+ # Turns the CLI's `ARGS='market_id=mkt_1 batch_size=200'` into the hash the
4
+ # task's ActiveModel attributes expect.
5
+ #
6
+ # Values stay strings: attribute types cast them, so the parser never has
7
+ # to guess whether `200` is a number or a label.
8
+ module ArgumentParser
9
+ # Quoted values may contain spaces: ARGS='label="two words" limit=5'.
10
+ TOKEN = /(\w+)=("[^"]*"|'[^']*'|\S+)/
11
+
12
+ # @param raw [String, nil]
13
+ # @return [Hash{String => String}]
14
+ def self.parse(raw)
15
+ return {} if raw.blank?
16
+
17
+ raw.scan(TOKEN).to_h do |key, value|
18
+ [key, value.gsub(/\A["']|["']\z/, '')]
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,51 @@
1
+ module Spree
2
+ module MaintenanceTasks
3
+ # Stops a run for good. The service records the request and the runner
4
+ # records the outcome; a run that has not started yet is cancelled
5
+ # outright, since no execution will ever observe the request.
6
+ #
7
+ # Cancelling a run that is already `cancelling` also cancels it outright.
8
+ # That is the way out when the worker that should have observed the first
9
+ # request is gone: a run the runner never comes back to would otherwise
10
+ # stay active and block every new run of its task.
11
+ #
12
+ # Cancelling is not a rollback — work already committed stays committed.
13
+ # What a cancelled run keeps is its cursor, so an operator can see exactly
14
+ # how far it got.
15
+ class Cancel
16
+ prepend ::Spree::ServiceModule::Base
17
+
18
+ # @param run [Spree::MaintenanceTaskRun]
19
+ # @return [Spree::ServiceModule::Base::Result] the run
20
+ def call(run:)
21
+ # Locked so the status read here is still the status when it is
22
+ # written: the runner may finish the run in between, and a finished
23
+ # run overwritten with `cancelling` would stay active for good.
24
+ run.with_lock do
25
+ return failure(run, not_cancelable_error(run)) unless run.active?
26
+
27
+ # A running run has a worker mid-batch, so it, not this service,
28
+ # records that the run stopped.
29
+ if run.running?
30
+ run.update!(status: 'cancelling')
31
+ else
32
+ run.update!(status: 'cancelled', ended_at: Time.current)
33
+ run.publish_event('maintenance_task_run.cancelled')
34
+ end
35
+ end
36
+
37
+ success(run.reload)
38
+ end
39
+
40
+ private
41
+
42
+ def not_cancelable_error(run)
43
+ build_error("a run with status #{run.status} cannot be cancelled")
44
+ end
45
+
46
+ def build_error(message)
47
+ ActiveModel::Errors.new(Spree::MaintenanceTaskRun.new).tap { |errors| errors.add(:base, message) }
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,127 @@
1
+ module Spree
2
+ module MaintenanceTasks
3
+ # Creates a run for a registered task and hands it to the runner
4
+ # (docs/plans/6.0-maintenance-tasks.md).
5
+ #
6
+ # Everything that can be known before any work happens is checked here —
7
+ # the task exists, its parameters validate, and no run of the same task
8
+ # is already in flight — so an operator sees a 422
9
+ # rather than a run that dies on its first record.
10
+ class Start
11
+ prepend ::Spree::ServiceModule::Base
12
+
13
+ # @param task_name [String] a registered Spree::MaintenanceTask subclass name
14
+ # @param api_type [String] the task's short name, in place of `task_name`
15
+ # — how an API caller names it, so a class name never has to cross
16
+ # the API
17
+ # @param arguments [Hash] task parameters
18
+ # @param dry_run [Boolean] preview mode; only honored by tasks that declare
19
+ # `supports_dry_run`
20
+ # @param initiated_via [String] dashboard / api / cli
21
+ # @param inline [Boolean] run synchronously (seeds, the CLI, the upgrade walk)
22
+ # @return [Spree::ServiceModule::Base::Result] the run
23
+ def call(task_name: nil, api_type: nil, arguments: {}, dry_run: false, admin_user: nil,
24
+ api_key: nil, initiated_via: 'api', inline: false)
25
+ task_class = if api_type.nil?
26
+ Spree::MaintenanceTask.find_registered(task_name)
27
+ else
28
+ Spree::MaintenanceTask.find_by_api_type(api_type)
29
+ end
30
+ return failure(nil, unknown_task_error(api_type || task_name)) if task_class.nil?
31
+
32
+ task_name = task_class.name
33
+
34
+ arguments = (arguments || {}).stringify_keys
35
+ unknown = arguments.keys - task_class.attribute_names
36
+ return failure(nil, unknown_arguments_error(unknown)) if unknown.any?
37
+
38
+ if initiated_via.to_s.in?(%w[api dashboard]) && superseded_step?(task_class, arguments)
39
+ return failure(nil, superseded_error(task_class))
40
+ end
41
+
42
+ task = task_class.new(arguments)
43
+ return failure(task, task.errors) unless task.valid?
44
+
45
+ active = active_run_for(task_name)
46
+ return failure(active, already_running_error(task_class)) if active
47
+
48
+ run = build_run(task_class, arguments, dry_run, admin_user, api_key, initiated_via)
49
+ return failure(run, run.errors) unless run.save
50
+
51
+ dispatch(run, inline)
52
+
53
+ success(run)
54
+ end
55
+
56
+ private
57
+
58
+ def build_run(task_class, arguments, dry_run, admin_user, api_key, initiated_via)
59
+ Spree::MaintenanceTaskRun.new(
60
+ task_name: task_class.name,
61
+ status: Spree::MaintenanceTaskRun.default_status,
62
+ arguments: arguments,
63
+ # A task that does not declare dry-run support would ignore the flag
64
+ # and write for real; recording it as a preview would be a lie.
65
+ dry_run: dry_run && task_class.dry_run_supported,
66
+ admin_user: admin_user,
67
+ api_key: api_key,
68
+ initiated_via: initiated_via.to_s
69
+ )
70
+ end
71
+
72
+ # The enqueue waits for the transaction so the worker cannot pick the
73
+ # job up before the row it names is visible.
74
+ def dispatch(run, inline)
75
+ if inline
76
+ ActiveRecord.after_all_transactions_commit { Spree::MaintenanceTasks::RunJob.perform_now(run.id, inline: true) }
77
+ else
78
+ Spree::MaintenanceTasks::RunJob.perform_later(run.id)
79
+ end
80
+ end
81
+
82
+ # Upgrade steps of a release this installation already completed. The
83
+ # dashboard never offers them; the command line still runs them, since
84
+ # that is where an operator deliberately repeats a step.
85
+ #
86
+ # Refused even when the completed release was assumed rather than
87
+ # recorded. The assumption is right for nearly every store, and for
88
+ # those a repeated conversion damages data; a store that really skipped
89
+ # a release is told to run its steps from the command line.
90
+ def superseded_step?(task_class, arguments)
91
+ Spree::Upgrade.superseded_steps.any? do |step|
92
+ step['task_class'] == task_class.name ||
93
+ (task_class <= Spree::MaintenanceTasks::UpgradeStep && step['id'] == arguments['step_id'])
94
+ end
95
+ end
96
+
97
+ # One run per task at a time. Checked rather than locked: two requests
98
+ # can both pass this check, and the runner then cancels the newer run
99
+ # before it does any work (RunJob#duplicate_run?).
100
+ def active_run_for(task_name)
101
+ Spree::MaintenanceTaskRun.active.find_by(task_name: task_name)
102
+ end
103
+
104
+ def unknown_task_error(task_name)
105
+ errors_for(:task_name, "#{task_name} is not a registered maintenance task")
106
+ end
107
+
108
+ def unknown_arguments_error(names)
109
+ errors_for(:arguments, "the task takes no parameter named #{names.to_sentence}")
110
+ end
111
+
112
+ def superseded_error(task_class)
113
+ errors_for(:base, "#{task_class.api_type} belongs to a release this store has already completed")
114
+ end
115
+
116
+ def already_running_error(task_class)
117
+ errors_for(:base, "#{task_class.api_type} is already running")
118
+ end
119
+
120
+ def errors_for(attribute, message)
121
+ ActiveModel::Errors.new(Spree::MaintenanceTaskRun.new).tap do |errors|
122
+ errors.add(attribute, message)
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -10,6 +10,8 @@ module Spree
10
10
  # countries gem, and zones are migration-only, so none of them
11
11
  # are seeded.
12
12
 
13
+ fresh_database = Spree::Store.none?
14
+
13
15
  Stores.call
14
16
  Spree::Store.find_each { |store| StoreResources.call(store: store) }
15
17
  # The warehouse, delivery zones and pickup are not seeded: their
@@ -18,6 +20,26 @@ module Spree
18
20
  # them from the merchant's answer — at first-run setup, or here
19
21
  # when ADMIN_EMAIL/ADMIN_PASSWORD name an install that skips it.
20
22
  AdminUser.call
23
+
24
+ # A database created by today's schema has no historical data to
25
+ # convert, so every upgrade boundary up to this release is already
26
+ # satisfied. Recording that is what keeps a fresh install from
27
+ # being shown a backlog of steps it must never run. Only a database
28
+ # seeded for the first time qualifies: seeding is also a deploy
29
+ # step, and an upgraded store seeded before its upgrade ran still
30
+ # has every step to do.
31
+ stamp_upgrade_boundaries if fresh_database
32
+ end
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def stamp_upgrade_boundaries
39
+ Spree::Upgrade.manifests.each do |manifest|
40
+ Spree::UpgradeRecord.find_or_create_by!(version: manifest['to']) do |record|
41
+ record.source = 'install'
42
+ record.completed_at = Time.current
21
43
  end
22
44
  end
23
45
  end