spree_core 6.0.2 → 6.0.3
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/addresses/geocode_address_job.rb +6 -3
- data/app/jobs/spree/variants/remove_line_item_job.rb +5 -0
- data/app/models/concerns/spree/preference_schema.rb +5 -0
- data/app/models/concerns/spree/typed_associations.rb +53 -20
- data/app/models/spree/address.rb +2 -2
- data/app/models/spree/collection.rb +7 -2
- data/app/models/spree/order.rb +1 -1
- data/app/models/spree/product.rb +15 -5
- data/app/models/spree/promotion.rb +1 -2
- data/app/services/spree/newsletter/subscribe.rb +1 -1
- data/app/services/spree/orders/create_from_cart.rb +6 -2
- data/app/tools/spree/agent_tools/create_export.rb +3 -1
- data/app/tools/spree/agent_tools/create_import.rb +1 -0
- data/app/tools/spree/agent_tools/create_media.rb +2 -1
- data/app/tools/spree/agent_tools/create_resource.rb +12 -4
- data/app/tools/spree/agent_tools/delete_resource.rb +12 -4
- data/app/tools/spree/agent_tools/describe_resource.rb +69 -6
- data/app/tools/spree/agent_tools/get_resource.rb +7 -4
- data/app/tools/spree/agent_tools/resource_write.rb +15 -2
- data/app/tools/spree/agent_tools/search_resources.rb +10 -11
- data/app/tools/spree/agent_tools/update_resource.rb +6 -6
- data/app/tools/spree/agent_tools/upload_file.rb +1 -0
- data/app/workflows/spree/carts/complete.rb +4 -1
- data/app/workflows/spree/carts/merge.rb +25 -4
- data/app/workflows/spree/orders/complete.rb +39 -14
- data/app/workflows/spree/products/activate.rb +7 -2
- data/app/workflows/spree/products/archive.rb +7 -2
- data/app/workflows/spree/products/draft.rb +7 -2
- data/app/workflows/spree/products/status_transition.rb +24 -0
- data/config/locales/en.yml +3 -0
- data/lib/generators/spree/api_resource/api_resource_generator.rb +35 -3
- data/lib/generators/spree/api_resource/templates/admin_controller.rb.tt +9 -4
- data/lib/generators/spree/api_resource/templates/admin_integration_spec.rb.tt +74 -0
- data/lib/generators/spree/api_resource/templates/store_controller.rb.tt +11 -6
- data/lib/spree/agent_tool.rb +105 -1
- data/lib/spree/agent_tools/default_catalog.rb +16 -21
- data/lib/spree/agent_tools/legacy_tools.rb +212 -0
- data/lib/spree/agent_tools/operation_catalog.rb +206 -0
- data/lib/spree/agent_tools/operation_tool.rb +189 -0
- data/lib/spree/agent_tools/registry.rb +65 -31
- data/lib/spree/agent_tools/resource_map.rb +128 -5
- data/lib/spree/agent_tools.rb +3 -3
- data/lib/spree/core/dependencies_helper.rb +96 -40
- data/lib/spree/core/engine.rb +2 -1
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/core.rb +19 -2
- data/lib/spree/testing_support/extension_rake.rb +12 -0
- data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +34 -0
- data/lib/tasks/dependencies.rake +2 -2
- metadata +23 -7
- data/lib/spree/agent_tools/workflow_endpoints.rb +0 -88
- data/lib/spree/agent_tools/workflow_schema.rb +0 -279
- data/lib/spree/agent_tools/workflow_tool.rb +0 -654
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a2157ca6ce976c9676d3c814939c6a69e8fd835b8864d73006b274f1216f2d4e
|
|
4
|
+
data.tar.gz: 8a80c4a0d322fafe8ff3f4accb3bf293bfca4f4cf341a98015e8a494c0373185
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 290b7d040a689ce9be4d01e20713ca97476d8b2b5ab1e8e735fad6bf651103375f037e2f96bb767662b866069d6c01219eae6d5d8da1de2ffed3a1e01756ab25
|
|
7
|
+
data.tar.gz: bd9a079e8712771a6a77453e9ae63bdf6a75a39d549c3db86eb326a5f7ab60896e6ff2196274380d5805f583487b43a24f0c198feb780ab40bddf11c1b0e26bb
|
|
@@ -16,11 +16,14 @@ module Spree
|
|
|
16
16
|
if coordinates.present?
|
|
17
17
|
address.update_columns(latitude: coordinates[0], longitude: coordinates[1], updated_at: Time.current)
|
|
18
18
|
else
|
|
19
|
-
#
|
|
20
|
-
#
|
|
19
|
+
# An address the geocoder cannot resolve is a data problem rather than
|
|
20
|
+
# a bug, so it is reported as a handled warning. Unfortunately there is
|
|
21
|
+
# no way to get the error message from Geocoder, but the request is
|
|
22
|
+
# fully displayed in the server logs
|
|
21
23
|
Rails.error.report(
|
|
22
24
|
GeocodeAddressError.new("Cannot geocode address ID: #{address.id}"),
|
|
23
|
-
handled:
|
|
25
|
+
handled: true,
|
|
26
|
+
severity: :warning,
|
|
24
27
|
context: { address_id: address_id },
|
|
25
28
|
source: 'spree.core'
|
|
26
29
|
)
|
|
@@ -7,6 +7,11 @@ module Spree
|
|
|
7
7
|
owner = line_item.owner
|
|
8
8
|
return if owner.nil?
|
|
9
9
|
|
|
10
|
+
# A completed cart is a read-only audit record; the order it became
|
|
11
|
+
# owns the lines now. The cart can complete between this job being
|
|
12
|
+
# enqueued and running, and there is nothing left to remove from it.
|
|
13
|
+
return if owner.is_a?(Spree::Cart) && owner.readonly?
|
|
14
|
+
|
|
10
15
|
# Carts and draft orders have different contracts: the cart side turns
|
|
11
16
|
# a vetoed removal into a warning and still succeeds, the order side
|
|
12
17
|
# fails outright. Routing an order-owned line through the cart
|
|
@@ -217,6 +217,11 @@ module Spree
|
|
|
217
217
|
registered_subclasses.find { |klass| klass.api_type == shorthand.to_s }
|
|
218
218
|
end
|
|
219
219
|
|
|
220
|
+
# @return [Array<String>] the wire name of every registered subclass
|
|
221
|
+
def api_types
|
|
222
|
+
registered_subclasses.map(&:api_type)
|
|
223
|
+
end
|
|
224
|
+
|
|
220
225
|
# Returns a `[{ type:, label:, description:, schema: }]` array for every
|
|
221
226
|
# registered subclass, sorted by label for stable output. `schema` is
|
|
222
227
|
# the JSON Schema of the subclass's `preferences`, which never carries a
|
|
@@ -37,37 +37,55 @@ module Spree
|
|
|
37
37
|
reconcile_typed_association(association, pending)
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
-
# Flushes
|
|
41
|
-
# an `after_save` hook) and clears
|
|
40
|
+
# Flushes the pending payloads from `@pending_<assoc>` (typically from
|
|
41
|
+
# an `after_save` hook) and clears them.
|
|
42
42
|
#
|
|
43
|
+
# Every association is written before any buffer is cleared: a refused
|
|
44
|
+
# row rolls the whole save back, and a retried save must carry every
|
|
45
|
+
# payload again rather than persist the owner with the rows that
|
|
46
|
+
# happened to come first.
|
|
47
|
+
#
|
|
48
|
+
# @param associations [Array<Symbol>]
|
|
49
|
+
# @return [void]
|
|
50
|
+
def flush_pending_typed_associations(*associations)
|
|
51
|
+
pending = associations.to_h { |association| [association, instance_variable_get(:"@pending_#{association}")] }.compact
|
|
52
|
+
pending.each { |association, rows| reconcile_typed_association(association, rows) }
|
|
53
|
+
pending.each_key { |association| instance_variable_set(:"@pending_#{association}", nil) }
|
|
54
|
+
end
|
|
55
|
+
|
|
43
56
|
# @param association [Symbol]
|
|
44
57
|
# @return [void]
|
|
45
58
|
def flush_pending_typed_association(association)
|
|
46
|
-
|
|
47
|
-
pending = instance_variable_get(ivar)
|
|
48
|
-
return unless pending
|
|
49
|
-
|
|
50
|
-
instance_variable_set(ivar, nil)
|
|
51
|
-
reconcile_typed_association(association, pending)
|
|
59
|
+
flush_pending_typed_associations(association)
|
|
52
60
|
end
|
|
53
61
|
|
|
62
|
+
# All or nothing: on a saved parent the rows are written as they are
|
|
63
|
+
# assigned, so a refusal on the second row used to leave the first one
|
|
64
|
+
# behind — a list the caller never asked for, answered with a 422.
|
|
54
65
|
def reconcile_typed_association(association, rows)
|
|
55
66
|
collection = public_send(association)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
67
|
+
begin
|
|
68
|
+
self.class.transaction do
|
|
69
|
+
kept_ids = rows.each_with_index.map { |row, index| save_typed_association_row(collection, row, "/#{association.to_s.split('_').last}/#{index}") }
|
|
70
|
+
collection.where.not(id: kept_ids).destroy_all
|
|
71
|
+
# `where.not(...).destroy_all` deletes the dropped rows from the DB but
|
|
72
|
+
# leaves them in the already-loaded association target. Reset so a
|
|
73
|
+
# same-request read (e.g. the serializer rendering `rule_ids`) reflects
|
|
74
|
+
# the removal instead of echoing ghosts.
|
|
75
|
+
collection.reset
|
|
76
|
+
end
|
|
77
|
+
rescue StandardError
|
|
78
|
+
# The rows built before the refusal are still in the loaded target, and
|
|
79
|
+
# the owner's next save would write them; the rollback only undid the
|
|
80
|
+
# database.
|
|
63
81
|
collection.reset
|
|
82
|
+
raise
|
|
64
83
|
end
|
|
65
84
|
end
|
|
66
85
|
|
|
67
86
|
# @param pointer [String] where the row sits in the request, e.g. `/rules/1`
|
|
68
87
|
def save_typed_association_row(collection, row, pointer)
|
|
69
|
-
record = find_or_build_typed_association_row(collection, row)
|
|
70
|
-
return nil unless record
|
|
88
|
+
record = find_or_build_typed_association_row(collection, row, pointer)
|
|
71
89
|
|
|
72
90
|
preferences = row[:preferences]
|
|
73
91
|
calculator = row[:calculator]
|
|
@@ -100,15 +118,19 @@ module Spree
|
|
|
100
118
|
raise
|
|
101
119
|
end
|
|
102
120
|
|
|
103
|
-
def find_or_build_typed_association_row(collection, row)
|
|
121
|
+
def find_or_build_typed_association_row(collection, row, pointer)
|
|
104
122
|
if row[:id].present?
|
|
105
123
|
id = Spree::PrefixedId.prefixed_id?(row[:id]) ? Spree::PrefixedId.decode_prefixed_id(row[:id]) : row[:id]
|
|
106
124
|
existing = collection.find { |r| r.id == id } || collection.find_by(id: id)
|
|
107
125
|
return existing if existing
|
|
108
126
|
end
|
|
109
127
|
|
|
110
|
-
|
|
111
|
-
|
|
128
|
+
family = collection.proxy_association.klass
|
|
129
|
+
klass = family.find_by_api_type(row[:type])
|
|
130
|
+
# Refused, not skipped: a dropped row answered the request with a 200
|
|
131
|
+
# and a promotion that discounted nothing. Naming the accepted types is
|
|
132
|
+
# what lets an API client or an agent correct itself.
|
|
133
|
+
raise unknown_row_type_error(family, row[:type], pointer) unless klass
|
|
112
134
|
|
|
113
135
|
# `build(type:) + becomes(klass)` would leave a stale STI-parent stub
|
|
114
136
|
# in the collection target alongside the becomes'd copy; autosave then
|
|
@@ -117,5 +139,16 @@ module Spree
|
|
|
117
139
|
collection.proxy_association.add_to_target(record)
|
|
118
140
|
record
|
|
119
141
|
end
|
|
142
|
+
|
|
143
|
+
def unknown_row_type_error(family, type, pointer)
|
|
144
|
+
types = family.api_types.sort.join(', ')
|
|
145
|
+
message = if type.blank?
|
|
146
|
+
I18n.t('spree.errors.messages.missing_row_type', types: types)
|
|
147
|
+
else
|
|
148
|
+
I18n.t('spree.errors.messages.unknown_row_type', type: type, family: family.model_name.human.downcase, types: types)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
Spree::Preferences::InvalidPreferences.new([{ pointer: '/type', message: message }], prefix: pointer)
|
|
152
|
+
end
|
|
120
153
|
end
|
|
121
154
|
end
|
data/app/models/spree/address.rb
CHANGED
|
@@ -270,10 +270,10 @@ module Spree
|
|
|
270
270
|
#
|
|
271
271
|
# @return [String, nil]
|
|
272
272
|
def company
|
|
273
|
-
stored =
|
|
273
|
+
stored = super
|
|
274
274
|
return stored if stored.present?
|
|
275
275
|
|
|
276
|
-
owner
|
|
276
|
+
owner&.name if owner_type == 'Spree::Company'
|
|
277
277
|
end
|
|
278
278
|
|
|
279
279
|
# Whether the owner prefills with this address. Every owner keeps the
|
|
@@ -203,10 +203,15 @@ module Spree
|
|
|
203
203
|
existing_by_id = rules.to_a.index_by(&:id)
|
|
204
204
|
retained_ids = []
|
|
205
205
|
|
|
206
|
-
rules_params.
|
|
206
|
+
rules_params.each_with_index do |rule_data, index|
|
|
207
207
|
data = rule_data.to_h.with_indifferent_access
|
|
208
208
|
rule_id = data.delete(:id)
|
|
209
|
-
data
|
|
209
|
+
if data.key?(:type) || rule_id.blank?
|
|
210
|
+
# Refused with the accepted kinds named; letting the nil through
|
|
211
|
+
# answered "type can't be blank" to a caller who had sent one.
|
|
212
|
+
data[:type] = self.class.resolve_rule_type(data[:type]) ||
|
|
213
|
+
raise(unknown_row_type_error(Spree::CollectionRule, data[:type], "/rules/#{index}"))
|
|
214
|
+
end
|
|
210
215
|
|
|
211
216
|
record = if rule_id.present?
|
|
212
217
|
existing_by_id[Spree::PrefixedId.decode_prefixed_id(rule_id) || rule_id] ||
|
data/app/models/spree/order.rb
CHANGED
|
@@ -829,7 +829,7 @@ module Spree
|
|
|
829
829
|
# reaches it through +Spree.carts_complete_workflow+). Removed in 6.1.
|
|
830
830
|
def finalize!
|
|
831
831
|
Spree::Deprecation.warn('Spree::Order#finalize! is deprecated and will be removed in Spree 6.1. Completion runs through Spree.order_complete_workflow (Spree::Orders::Complete).')
|
|
832
|
-
Spree.order_complete_workflow.call(order: self, payment_pending: true)
|
|
832
|
+
Spree.order_complete_workflow.call(order: self, payment_pending: true, validated: true)
|
|
833
833
|
end
|
|
834
834
|
|
|
835
835
|
def fulfill!
|
data/app/models/spree/product.rb
CHANGED
|
@@ -746,15 +746,25 @@ module Spree
|
|
|
746
746
|
variants.active(current_currency).joins(:option_value_variants)
|
|
747
747
|
end
|
|
748
748
|
|
|
749
|
+
# Units purchasable now across every variant: on hand, less what orders
|
|
750
|
+
# have taken and what checkouts are holding, at active locations, floored
|
|
751
|
+
# at zero per variant. Infinite when any variant does not track inventory.
|
|
749
752
|
def total_on_hand
|
|
750
753
|
@total_on_hand ||= if any_variants_not_track_inventory?
|
|
751
754
|
BigDecimal::INFINITY
|
|
755
|
+
elsif variants.loaded?
|
|
756
|
+
variants.sum(&:total_on_hand)
|
|
752
757
|
else
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
+
# The same figure in one query: the per-variant floor
|
|
759
|
+
# needs the sums grouped before they are added up.
|
|
760
|
+
# Unordered, since the variants' position order cannot
|
|
761
|
+
# sit beside a GROUP BY on PostgreSQL. Holds count only
|
|
762
|
+
# while the store reserves stock, as on each variant.
|
|
763
|
+
levels = Spree::StockLevel.arel_table
|
|
764
|
+
purchasable = levels[:count_on_hand] - levels[:allocated_count]
|
|
765
|
+
purchasable -= levels[:reserved_count] if store_preference(:stock_reservations_enabled)
|
|
766
|
+
stock_levels.with_active_stock_location.unscope(:order).group(:variant_id).
|
|
767
|
+
sum(purchasable).values.sum { |count| [count, 0].max }
|
|
758
768
|
end
|
|
759
769
|
end
|
|
760
770
|
|
|
@@ -373,8 +373,7 @@ module Spree
|
|
|
373
373
|
end
|
|
374
374
|
|
|
375
375
|
def apply_pending_rules_and_actions
|
|
376
|
-
|
|
377
|
-
flush_pending_typed_association(:promotion_actions)
|
|
376
|
+
flush_pending_typed_associations(:promotion_rules, :promotion_actions)
|
|
378
377
|
end
|
|
379
378
|
|
|
380
379
|
# Runs before the promotion's actions are destroyed, since the carts'
|
|
@@ -20,7 +20,7 @@ module Spree
|
|
|
20
20
|
|
|
21
21
|
Spree::Newsletter::LinkUser.new(subscriber: subscriber, user: known_user).call
|
|
22
22
|
|
|
23
|
-
if subscriber.email
|
|
23
|
+
if current_user && subscriber.email.to_s.casecmp?(current_user.email.to_s)
|
|
24
24
|
# User's email is already verified by login — skip the double opt-in.
|
|
25
25
|
Spree::Newsletter::Verify.new(subscriber: subscriber).call
|
|
26
26
|
end
|
|
@@ -25,7 +25,8 @@ module Spree
|
|
|
25
25
|
order = build_order(cart, customer: customer, created_by: created_by)
|
|
26
26
|
order.save!
|
|
27
27
|
|
|
28
|
-
copy_line_items(cart, order)
|
|
28
|
+
line_item_map = copy_line_items(cart, order)
|
|
29
|
+
Spree.cart_copiers.each { |copier| copier.call(cart: cart, order: order, line_item_map: line_item_map) }
|
|
29
30
|
estimate_taxes(order)
|
|
30
31
|
build_fulfillments(order)
|
|
31
32
|
order.recalculate_totals!
|
|
@@ -60,12 +61,15 @@ module Spree
|
|
|
60
61
|
)
|
|
61
62
|
end
|
|
62
63
|
|
|
64
|
+
# @return [Hash{Spree::LineItem => Spree::LineItem}] cart line item → its
|
|
65
|
+
# copy on the order, the map {Spree.cart_copiers} receive
|
|
63
66
|
def copy_line_items(cart, order)
|
|
64
|
-
cart.line_items.
|
|
67
|
+
cart.line_items.to_a.index_with do |cart_line_item|
|
|
65
68
|
line_item = order.line_items.new(cart_line_item.attributes.slice(*LINE_ITEM_COPIED_ATTRIBUTES))
|
|
66
69
|
# Estimated once for the batch below, not once per line.
|
|
67
70
|
line_item.skip_tax_estimation = true
|
|
68
71
|
line_item.save!
|
|
72
|
+
line_item
|
|
69
73
|
end
|
|
70
74
|
end
|
|
71
75
|
|
|
@@ -4,7 +4,8 @@ module Spree
|
|
|
4
4
|
#
|
|
5
5
|
# Mutating not because it changes the store — it does not — but because
|
|
6
6
|
# it produces a file of store data and emails a link to it. That leaves
|
|
7
|
-
# the building, so a merchant decides, not the model.
|
|
7
|
+
# the building, so a merchant decides, not the model. Additive, since
|
|
8
|
+
# nothing in the store is overwritten.
|
|
8
9
|
class CreateExport < Spree::AgentTool
|
|
9
10
|
tool_name 'create_export'
|
|
10
11
|
description 'Export records to a CSV file. Say which kind of records and, ' \
|
|
@@ -25,6 +26,7 @@ module Spree
|
|
|
25
26
|
|
|
26
27
|
permission nil
|
|
27
28
|
mutating!
|
|
29
|
+
additive!
|
|
28
30
|
|
|
29
31
|
param :resource, description: 'What to export: products, orders, customers',
|
|
30
32
|
required: true
|
|
@@ -16,6 +16,7 @@ module Spree
|
|
|
16
16
|
'the file sits in the library for the merchant to place later.'
|
|
17
17
|
permission 'write_media'
|
|
18
18
|
mutating!
|
|
19
|
+
additive!
|
|
19
20
|
|
|
20
21
|
param :file_id, description: 'The file_id upload_file returned', required: true
|
|
21
22
|
param :product_id, description: "A product's prefixed id, to place the file in its gallery"
|
|
@@ -27,7 +28,7 @@ module Spree
|
|
|
27
28
|
# endpoint resolves the parent, checks it and verifies the signed id,
|
|
28
29
|
# so there is nothing to look up here first.
|
|
29
30
|
path = if product_id.present?
|
|
30
|
-
"/api/v3/admin/products/#{product_id}/media"
|
|
31
|
+
"/api/v3/admin/products/#{ERB::Util.url_encode(product_id)}/media"
|
|
31
32
|
else
|
|
32
33
|
'/api/v3/admin/media'
|
|
33
34
|
end
|
|
@@ -13,23 +13,31 @@ module Spree
|
|
|
13
13
|
|
|
14
14
|
param :resource, description: 'Resource key — call describe_resource for the list', required: true
|
|
15
15
|
param :attributes, type: :object, description: 'Attributes to set on the new record', required: true
|
|
16
|
+
param :parent_id, description: PARENT_ID_DESCRIPTION
|
|
16
17
|
|
|
17
|
-
def call(resource:, attributes: {})
|
|
18
|
+
def call(resource:, attributes: {}, parent_id: nil)
|
|
18
19
|
entry, refusal = writable_entry(resource, :create)
|
|
19
20
|
return refusal if refusal
|
|
20
21
|
|
|
21
22
|
body, rejection = body_for(entry, attributes)
|
|
22
23
|
return rejection if rejection
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
path, missing = write_path(entry, :create, parent_id: parent_id)
|
|
26
|
+
return missing if missing
|
|
25
27
|
|
|
26
|
-
|
|
28
|
+
response = dispatch.call(method: :post, path: path, body: body)
|
|
29
|
+
|
|
30
|
+
result_for(response, entry, summary: summary(resource: resource, attributes: attributes, parent_id: parent_id))
|
|
27
31
|
end
|
|
28
32
|
|
|
29
33
|
def summary(arguments)
|
|
30
34
|
named = arguments[:attributes].to_h.values_at('name', :name, 'number', :number).compact.first
|
|
31
35
|
|
|
32
|
-
"Create #{arguments[:resource].to_s.singularize.tr('_', ' ')} #{named}".strip
|
|
36
|
+
line = "Create #{arguments[:resource].to_s.singularize.tr('_', ' ')} #{named}".strip
|
|
37
|
+
|
|
38
|
+
# A top-level resource ignores parent_id, so the approval card does too.
|
|
39
|
+
nested = arguments[:parent_id].present? && ResourceMap.find(arguments[:resource])&.parent_param(:create)
|
|
40
|
+
nested ? "#{line} under #{arguments[:parent_id]}" : line
|
|
33
41
|
end
|
|
34
42
|
end
|
|
35
43
|
end
|
|
@@ -9,19 +9,27 @@ module Spree
|
|
|
9
9
|
|
|
10
10
|
param :resource, description: 'Resource key — call describe_resource for the list', required: true
|
|
11
11
|
param :id, description: 'The record id as shown in search results', required: true
|
|
12
|
+
param :parent_id, description: PARENT_ID_DESCRIPTION
|
|
12
13
|
|
|
13
|
-
def call(resource:, id:)
|
|
14
|
+
def call(resource:, id:, parent_id: nil)
|
|
14
15
|
entry, refusal = writable_entry(resource, :destroy)
|
|
15
16
|
return refusal if refusal
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
path, missing = write_path(entry, :destroy, id: id, parent_id: parent_id)
|
|
19
|
+
return missing if missing
|
|
20
|
+
|
|
21
|
+
response = dispatch.call(method: :delete, path: path)
|
|
18
22
|
return { error: response.error_message } unless response.success?
|
|
19
23
|
|
|
20
|
-
{ summary: summary(resource: resource, id: id) }
|
|
24
|
+
{ summary: summary(resource: resource, id: id, parent_id: parent_id) }
|
|
21
25
|
end
|
|
22
26
|
|
|
23
27
|
def summary(arguments)
|
|
24
|
-
"Delete #{arguments[:resource].to_s.singularize.tr('_', ' ')} #{arguments[:id]}"
|
|
28
|
+
line = "Delete #{arguments[:resource].to_s.singularize.tr('_', ' ')} #{arguments[:id]}"
|
|
29
|
+
# The approval card is all a merchant sees, so a nested delete names
|
|
30
|
+
# the record it runs under, as a nested create does.
|
|
31
|
+
nested = arguments[:parent_id].present? && ResourceMap.find(arguments[:resource])&.parent_param(:destroy)
|
|
32
|
+
nested ? "#{line} under #{arguments[:parent_id]}" : line
|
|
25
33
|
end
|
|
26
34
|
end
|
|
27
35
|
end
|
|
@@ -5,9 +5,11 @@ module Spree
|
|
|
5
5
|
# producing errors the merchant has to read.
|
|
6
6
|
class DescribeResource < Spree::AgentTool
|
|
7
7
|
tool_name 'describe_resource'
|
|
8
|
-
description 'List the store resources you can search,
|
|
9
|
-
'
|
|
10
|
-
'
|
|
8
|
+
description 'List the store resources you can search, the fields each one can be ' \
|
|
9
|
+
'filtered and sorted by, and what a write accepts. Describe one resource ' \
|
|
10
|
+
'for the full detail: what fits inside each list-valued attribute, and the ' \
|
|
11
|
+
'kinds a `type` field takes with each kind\'s preferences schema. Call this ' \
|
|
12
|
+
'before search_resources, create_resource or update_resource if unsure.'
|
|
11
13
|
# Deliberately ungated: it exposes only field names, and the resources it
|
|
12
14
|
# lists are already filtered to what this admin may read.
|
|
13
15
|
permission nil
|
|
@@ -25,11 +27,47 @@ module Spree
|
|
|
25
27
|
available: Spree::AgentTools::ResourceMap.available_for(context).map(&:key) }
|
|
26
28
|
end
|
|
27
29
|
|
|
28
|
-
|
|
30
|
+
# One resource gets every type's schema; the listing names the types
|
|
31
|
+
# only, because every family's schema at once is more than an index
|
|
32
|
+
# should cost a model's context.
|
|
33
|
+
{ resources: entries.map { |entry| describe(entry, detailed: resource.present?) } }
|
|
29
34
|
end
|
|
30
35
|
|
|
31
36
|
private
|
|
32
37
|
|
|
38
|
+
# The kinds a `type` takes, from the family's own registry — the same
|
|
39
|
+
# list its `…/types` endpoint serves. An agent that never saw this list
|
|
40
|
+
# guessed Ruby class names, and the write dropped every row.
|
|
41
|
+
def types_for(family, detailed)
|
|
42
|
+
return if family.nil?
|
|
43
|
+
|
|
44
|
+
return family.api_types.sort unless detailed
|
|
45
|
+
|
|
46
|
+
family.subclasses_with_preference_schema.map do |kind|
|
|
47
|
+
kind.slice(:type, :label, :description, :schema).merge(calculators: calculators_for(family, kind[:type])).compact
|
|
48
|
+
end
|
|
49
|
+
rescue StandardError
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# An action that prices its discount through a calculator takes one of
|
|
54
|
+
# these, each with its own preferences — the same list as
|
|
55
|
+
# `GET /promotion_actions/calculators`.
|
|
56
|
+
def calculators_for(family, type)
|
|
57
|
+
klass = family.find_by_api_type(type)
|
|
58
|
+
return unless klass.respond_to?(:calculators)
|
|
59
|
+
|
|
60
|
+
klass.calculators.map do |calculator|
|
|
61
|
+
{ type: calculator.api_type, label: calculator.description, schema: calculator.preference_json_schema }
|
|
62
|
+
end.sort_by { |entry| entry[:label].to_s }.presence
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def nested_types_for(entry, detailed)
|
|
66
|
+
return if writable_attributes_for(entry).blank?
|
|
67
|
+
|
|
68
|
+
entry.nested_typed_families.transform_values { |family| types_for(family, detailed) }.compact.presence
|
|
69
|
+
end
|
|
70
|
+
|
|
33
71
|
# Every dispatchable resource reports its attributes now: the generic
|
|
34
72
|
# writes reach the controller, which runs whatever workflow it
|
|
35
73
|
# declares, so there is no longer a class of resource they refuse.
|
|
@@ -46,19 +84,43 @@ module Spree
|
|
|
46
84
|
entry.writable_attribute_names.presence
|
|
47
85
|
end
|
|
48
86
|
|
|
49
|
-
|
|
87
|
+
# The action tools this caller is offered, by resource, worked out once
|
|
88
|
+
# per call rather than once per resource described.
|
|
89
|
+
def actions_for(entry)
|
|
90
|
+
@actions ||= Spree.admin_agent_tools.to_a.grep(->(klass) { klass < Spree::AgentTools::OperationTool }).
|
|
91
|
+
select { |klass| context.permitted?(klass.permission) }.
|
|
92
|
+
group_by { |klass| klass.operation.resource_key }.
|
|
93
|
+
transform_values { |classes| classes.map(&:tool_name).sort }
|
|
94
|
+
@actions[entry.key]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def describe(entry, detailed:)
|
|
50
98
|
{
|
|
51
99
|
resource: entry.key,
|
|
100
|
+
# A resource reached through another record — an order's items, a
|
|
101
|
+
# product's variants when written — needs that record's id as
|
|
102
|
+
# parent_id on the calls whose route runs under it.
|
|
103
|
+
parent: %i[index create update].lazy.filter_map { |action| entry.parent_key(action) }.first,
|
|
52
104
|
filterable_fields: entry.filterable_fields,
|
|
53
105
|
# What a generic write accepts, so the model learns the schema here
|
|
54
106
|
# rather than by sending an invalid write and reading the refusal —
|
|
55
107
|
# which is what create_resource and update_resource tell it to do.
|
|
56
108
|
# Offered only where the caller could actually write.
|
|
57
109
|
writable_attributes: writable_attributes_for(entry),
|
|
110
|
+
# The action tools that act on this resource — cancel, approve,
|
|
111
|
+
# capture — so a model that found an order learns what it can do
|
|
112
|
+
# to one without scanning the whole tool list.
|
|
113
|
+
actions: actions_for(entry),
|
|
58
114
|
# What goes inside a list-valued attribute. Without it a model sees
|
|
59
115
|
# `variants` and has no idea a whole variant fits there, so it
|
|
60
116
|
# creates the product and then adds each variant separately.
|
|
61
117
|
nested_attributes: nested_shapes_for(entry),
|
|
118
|
+
# The kinds this resource's own `type` takes (a payment method's
|
|
119
|
+
# provider), and the kinds each nested row's `type` takes (a
|
|
120
|
+
# promotion's rules and actions). The accepted values and their
|
|
121
|
+
# preference schemas, so a model does not guess class names.
|
|
122
|
+
types: types_for(entry.typed_family, detailed),
|
|
123
|
+
nested_types: nested_types_for(entry, detailed),
|
|
62
124
|
# What get_resource can be asked to include. A record arrives
|
|
63
125
|
# without its associations, so a model that does not know these
|
|
64
126
|
# names cannot ask for the one it needs.
|
|
@@ -73,7 +135,8 @@ module Spree
|
|
|
73
135
|
filterable_scopes: entry.filterable_scopes,
|
|
74
136
|
usage: 'Attribute filters use Ransack predicates, e.g. name_cont, created_at_gteq, ' \
|
|
75
137
|
'status_eq. Named scopes are passed as a bare key set to true, e.g. ' \
|
|
76
|
-
'{"out_of_stock": true}.'
|
|
138
|
+
'{"out_of_stock": true}. A type field takes one of the kinds under types or ' \
|
|
139
|
+
'nested_types, and a row\'s preferences follow that kind\'s schema.'
|
|
77
140
|
}.compact
|
|
78
141
|
end
|
|
79
142
|
end
|
|
@@ -14,20 +14,23 @@ module Spree
|
|
|
14
14
|
param :id, description: 'The record id as shown in search results', required: true
|
|
15
15
|
param :expand, type: :array, items: :string,
|
|
16
16
|
description: "Associations to include, e.g. [\"variants\"] on a product"
|
|
17
|
+
param :parent_id, description: PARENT_ID_DESCRIPTION
|
|
17
18
|
|
|
18
|
-
def call(resource:, id:, expand: nil)
|
|
19
|
+
def call(resource:, id:, expand: nil, parent_id: nil)
|
|
19
20
|
entry = ResourceMap.find(resource)
|
|
20
21
|
return unknown_resource(resource) if entry.nil?
|
|
21
22
|
return { error: "You do not have permission to read #{entry.key}." } unless
|
|
22
23
|
context.permitted?(entry.permission)
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
return { error: "#{entry.key} cannot be fetched one at a time." } if entry.api_path(:show).blank?
|
|
26
|
+
|
|
27
|
+
path = entry.path_for(:show, id: id, parent_id: parent_id)
|
|
28
|
+
return nested_resource_error(entry, 'fetched', :show) if path.nil?
|
|
26
29
|
|
|
27
30
|
requested, refusal = expansions(entry, expand)
|
|
28
31
|
return refusal if refusal
|
|
29
32
|
|
|
30
|
-
response = dispatch.call(method: :get, path: path
|
|
33
|
+
response = dispatch.call(method: :get, path: path,
|
|
31
34
|
params: requested.any? ? { expand: requested.join(',') } : {})
|
|
32
35
|
return { error: not_found(entry, id, response) } unless response.success?
|
|
33
36
|
|
|
@@ -77,6 +77,17 @@ module Spree
|
|
|
77
77
|
accepted_attributes: accepted }]
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
+
# The path for this write, or the refusal saying which record a nested
|
|
81
|
+
# resource needs.
|
|
82
|
+
#
|
|
83
|
+
# @return [Array(String, nil), Array(nil, Hash)]
|
|
84
|
+
def write_path(entry, action, id: nil, parent_id: nil)
|
|
85
|
+
path = entry.path_for(action, id: id, parent_id: parent_id)
|
|
86
|
+
return [path, nil] if path
|
|
87
|
+
|
|
88
|
+
[nil, nested_resource_error(entry, verb_for(action), action)]
|
|
89
|
+
end
|
|
90
|
+
|
|
80
91
|
def result_for(response, entry, summary:)
|
|
81
92
|
return { error: response.error_message } unless response.success?
|
|
82
93
|
|
|
@@ -90,9 +101,11 @@ module Spree
|
|
|
90
101
|
end
|
|
91
102
|
|
|
92
103
|
def not_writable(entry, action)
|
|
93
|
-
|
|
104
|
+
"#{entry.key} cannot be #{verb_for(action)} this way — call describe_resource to see what can."
|
|
105
|
+
end
|
|
94
106
|
|
|
95
|
-
|
|
107
|
+
def verb_for(action)
|
|
108
|
+
{ create: 'created', update: 'updated', destroy: 'deleted' }.fetch(action, 'changed')
|
|
96
109
|
end
|
|
97
110
|
end
|
|
98
111
|
end
|
|
@@ -26,11 +26,12 @@ module Spree
|
|
|
26
26
|
param :filters, type: :object, description: 'Ransack filter hash, e.g. {"name_cont": "shirt"}'
|
|
27
27
|
param :sort, description: 'Sort, e.g. "created_at desc"'
|
|
28
28
|
param :limit, type: :integer, description: "Max records to return (default #{DEFAULT_LIMIT})"
|
|
29
|
+
param :parent_id, description: PARENT_ID_DESCRIPTION
|
|
29
30
|
|
|
30
|
-
def call(resource:, filters: nil, sort: nil, limit: nil)
|
|
31
|
+
def call(resource:, filters: nil, sort: nil, limit: nil, parent_id: nil)
|
|
31
32
|
entry = ResourceMap.find(resource)
|
|
32
33
|
return unknown_resource(resource) if entry.nil?
|
|
33
|
-
return not_searchable(entry)
|
|
34
|
+
return not_searchable(entry) if entry.api_path(:index).blank?
|
|
34
35
|
return forbidden(entry) unless context.permitted?(entry.permission)
|
|
35
36
|
|
|
36
37
|
applied = sanitize_filters(filters)
|
|
@@ -42,11 +43,10 @@ module Spree
|
|
|
42
43
|
unrecognised = unrecognised_filters(entry, applied)
|
|
43
44
|
return invalid_filters(entry, unrecognised) if unrecognised.any?
|
|
44
45
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
)
|
|
46
|
+
path = entry.path_for(:index, parent_id: parent_id)
|
|
47
|
+
return not_searchable(entry) if path.nil?
|
|
48
|
+
|
|
49
|
+
response = dispatch.call(method: :get, path: path, params: query_for(applied, sort, limit))
|
|
50
50
|
return { error: response.error_message } unless response.success?
|
|
51
51
|
|
|
52
52
|
rows = Array(response.data)
|
|
@@ -145,10 +145,9 @@ module Spree
|
|
|
145
145
|
# resolves its scope per request. Named rather than guessed at, because
|
|
146
146
|
# a tool cannot invent the parent id such a path needs.
|
|
147
147
|
def not_searchable(entry)
|
|
148
|
-
{
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
148
|
+
refusal = entry.nested?(:index) ? nested_resource_error(entry, 'searched', :index) : { error: "#{entry.key} cannot be searched on its own." }
|
|
149
|
+
|
|
150
|
+
refusal.merge(available: ResourceMap.available_for(context).select(&:dispatchable?).map(&:key).sort)
|
|
152
151
|
end
|
|
153
152
|
end
|
|
154
153
|
end
|