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.
- checksums.yaml +4 -4
- data/app/jobs/spree/maintenance_tasks/run_job.rb +321 -0
- data/app/jobs/spree/seller_transfers/execute_pending_due_job.rb +7 -5
- data/app/jobs/spree/seller_transfers/execute_pending_job.rb +37 -17
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/backfill_order_markets.rb +48 -0
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/capture_methods.rb +40 -0
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/deliveries.rb +15 -0
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/order_coupon_codes.rb +15 -0
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/promotion_option_value_rules.rb +15 -0
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/rake_step.rb +83 -0
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/returns.rb +20 -0
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/store_regional_settings.rb +63 -0
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade/users_to_customers.rb +28 -0
- data/app/maintenance_tasks/spree/maintenance_tasks/upgrade_step.rb +44 -0
- data/app/models/concerns/spree/translatable_resource.rb +19 -0
- data/app/models/spree/ability.rb +9 -0
- data/app/models/spree/invitation.rb +11 -2
- data/app/models/spree/maintenance_task.rb +221 -0
- data/app/models/spree/maintenance_task_run.rb +78 -0
- data/app/models/spree/order_routing/strategy/rules.rb +8 -48
- data/app/models/spree/seller_transfer.rb +51 -0
- data/app/models/spree/stock/coordinator.rb +37 -17
- data/app/models/spree/stock/packer.rb +19 -9
- data/app/models/spree/stock/prioritizer.rb +30 -15
- data/app/models/spree/store.rb +35 -2
- data/app/models/spree/upgrade_record.rb +62 -0
- data/app/services/spree/carts/update.rb +2 -1
- data/app/services/spree/maintenance_tasks/argument_parser.rb +23 -0
- data/app/services/spree/maintenance_tasks/cancel.rb +51 -0
- data/app/services/spree/maintenance_tasks/start.rb +127 -0
- data/app/services/spree/seeds/all.rb +22 -0
- data/app/services/spree/stock_reservations/reserve.rb +73 -25
- data/app/workflows/spree/seller_transfers/reverse.rb +5 -44
- data/config/locales/en.yml +1 -0
- data/db/migrate/20260813130001_add_capture_method_to_payment_methods.rb +1 -1
- data/db/migrate/20260815000001_create_spree_maintenance_task_runs.rb +40 -0
- data/db/migrate/20260816000001_create_spree_upgrade_records.rb +17 -0
- data/db/migrate/20261029100001_add_lockout_columns_to_spree_admin_users.rb +8 -0
- data/lib/generators/spree/api_resource/api_resource_generator.rb +2 -2
- data/lib/spree/checkout/default_requirements.rb +33 -5
- data/lib/spree/core/configuration.rb +6 -0
- data/lib/spree/core/engine.rb +10 -1
- data/lib/spree/core/permissions/default_catalog.rb +7 -0
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/core.rb +17 -1
- data/lib/spree/testing_support/factories/maintenance_task_run_factory.rb +7 -0
- data/lib/spree/time_zones.rb +34 -0
- data/lib/spree/upgrade.rb +189 -0
- data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +30 -10
- data/lib/tasks/maintenance_tasks.rake +42 -0
- data/lib/tasks/upgrade.rake +81 -60
- metadata +28 -6
- data/lib/tasks/capture_methods_migration.rake +0 -30
- 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
|
-
|
|
26
|
-
packages = prioritize_packages(packages)
|
|
27
|
-
packages = estimate_packages(packages)
|
|
25
|
+
estimate_packages(allocated_packages)
|
|
28
26
|
end
|
|
29
27
|
|
|
30
|
-
|
|
31
|
-
|
|
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.
|
|
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,
|
|
54
|
+
units_by_variant_id.each do |variant_id, units|
|
|
55
55
|
variant = variants[variant_id] || Spree::Variant.find(variant_id)
|
|
56
|
-
|
|
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,
|
|
62
|
-
|
|
63
|
-
|
|
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,
|
|
88
|
-
loaded[variant_id] =
|
|
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
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
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
|
data/app/models/spree/store.rb
CHANGED
|
@@ -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
|