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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/app/jobs/spree/addresses/geocode_address_job.rb +6 -3
  3. data/app/jobs/spree/variants/remove_line_item_job.rb +5 -0
  4. data/app/models/concerns/spree/preference_schema.rb +5 -0
  5. data/app/models/concerns/spree/typed_associations.rb +53 -20
  6. data/app/models/spree/address.rb +2 -2
  7. data/app/models/spree/collection.rb +7 -2
  8. data/app/models/spree/order.rb +1 -1
  9. data/app/models/spree/product.rb +15 -5
  10. data/app/models/spree/promotion.rb +1 -2
  11. data/app/services/spree/newsletter/subscribe.rb +1 -1
  12. data/app/services/spree/orders/create_from_cart.rb +6 -2
  13. data/app/tools/spree/agent_tools/create_export.rb +3 -1
  14. data/app/tools/spree/agent_tools/create_import.rb +1 -0
  15. data/app/tools/spree/agent_tools/create_media.rb +2 -1
  16. data/app/tools/spree/agent_tools/create_resource.rb +12 -4
  17. data/app/tools/spree/agent_tools/delete_resource.rb +12 -4
  18. data/app/tools/spree/agent_tools/describe_resource.rb +69 -6
  19. data/app/tools/spree/agent_tools/get_resource.rb +7 -4
  20. data/app/tools/spree/agent_tools/resource_write.rb +15 -2
  21. data/app/tools/spree/agent_tools/search_resources.rb +10 -11
  22. data/app/tools/spree/agent_tools/update_resource.rb +6 -6
  23. data/app/tools/spree/agent_tools/upload_file.rb +1 -0
  24. data/app/workflows/spree/carts/complete.rb +4 -1
  25. data/app/workflows/spree/carts/merge.rb +25 -4
  26. data/app/workflows/spree/orders/complete.rb +39 -14
  27. data/app/workflows/spree/products/activate.rb +7 -2
  28. data/app/workflows/spree/products/archive.rb +7 -2
  29. data/app/workflows/spree/products/draft.rb +7 -2
  30. data/app/workflows/spree/products/status_transition.rb +24 -0
  31. data/config/locales/en.yml +3 -0
  32. data/lib/generators/spree/api_resource/api_resource_generator.rb +35 -3
  33. data/lib/generators/spree/api_resource/templates/admin_controller.rb.tt +9 -4
  34. data/lib/generators/spree/api_resource/templates/admin_integration_spec.rb.tt +74 -0
  35. data/lib/generators/spree/api_resource/templates/store_controller.rb.tt +11 -6
  36. data/lib/spree/agent_tool.rb +105 -1
  37. data/lib/spree/agent_tools/default_catalog.rb +16 -21
  38. data/lib/spree/agent_tools/legacy_tools.rb +212 -0
  39. data/lib/spree/agent_tools/operation_catalog.rb +206 -0
  40. data/lib/spree/agent_tools/operation_tool.rb +189 -0
  41. data/lib/spree/agent_tools/registry.rb +65 -31
  42. data/lib/spree/agent_tools/resource_map.rb +128 -5
  43. data/lib/spree/agent_tools.rb +3 -3
  44. data/lib/spree/core/dependencies_helper.rb +96 -40
  45. data/lib/spree/core/engine.rb +2 -1
  46. data/lib/spree/core/version.rb +1 -1
  47. data/lib/spree/core.rb +19 -2
  48. data/lib/spree/testing_support/extension_rake.rb +12 -0
  49. data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +34 -0
  50. data/lib/tasks/dependencies.rake +2 -2
  51. metadata +23 -7
  52. data/lib/spree/agent_tools/workflow_endpoints.rb +0 -88
  53. data/lib/spree/agent_tools/workflow_schema.rb +0 -279
  54. 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: 95b9df74c721a763fd81c0ca389f4bf996400ed8e1a262ca031228387157be16
4
- data.tar.gz: ff8cd8cc1294aaa4eea49a2fbcd0c6319b15ed59d77988af865d48ae56782bf2
3
+ metadata.gz: a2157ca6ce976c9676d3c814939c6a69e8fd835b8864d73006b274f1216f2d4e
4
+ data.tar.gz: 8a80c4a0d322fafe8ff3f4accb3bf293bfca4f4cf341a98015e8a494c0373185
5
5
  SHA512:
6
- metadata.gz: 94d512c61c6b2e6aa3c8672c883ea406af8efb8c509fe7ba0f0a49d58c02aa767d23a9ff82e8532965e32217130aeff39074c78d5b8d0896de6f8dac9e7e19e7
7
- data.tar.gz: cb944067256bc44223bca95b0c00bcf17f242745c2885ea063d7549592ebdbb013c0138359fb60326b662ae4b4ed583fdba553fc2f42378fa3952049b69d7af2
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
- # Unfortunately there is no way to get the error message from Geocoder,
20
- # but the request is fully displayed in the server logs
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: false,
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 a pending payload from `@pending_<assoc>` (typically from
41
- # an `after_save` hook) and clears the ivar.
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
- ivar = :"@pending_#{association}"
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
- kept_ids = rows.each_with_index.filter_map { |row, index| save_typed_association_row(collection, row, "/#{association.to_s.split('_').last}/#{index}") }
57
- if kept_ids.any? || rows.empty?
58
- collection.where.not(id: kept_ids).destroy_all
59
- # `where.not(...).destroy_all` deletes the dropped rows from the DB but
60
- # leaves them in the already-loaded association target. Reset so a
61
- # same-request read (e.g. the serializer rendering `rule_ids`) reflects
62
- # the removal instead of echoing ghosts.
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
- klass = collection.proxy_association.klass.find_by_api_type(row[:type])
111
- return nil unless klass
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
@@ -270,10 +270,10 @@ module Spree
270
270
  #
271
271
  # @return [String, nil]
272
272
  def company
273
- stored = read_attribute(:company)
273
+ stored = super
274
274
  return stored if stored.present?
275
275
 
276
- owner.name if owner.is_a?(Spree::Company)
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.each do |rule_data|
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[:type] = self.class.resolve_rule_type(data[:type]) if data.key?(:type)
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] ||
@@ -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!
@@ -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
- if variants.loaded?
754
- variants.sum(&:total_on_hand)
755
- else
756
- stock_levels.loaded? ? stock_levels.sum(&:available_count) : stock_levels.sum('count_on_hand - allocated_count')
757
- end
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
- flush_pending_typed_association(:promotion_rules)
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 == current_user&.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.each do |cart_line_item|
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
@@ -21,6 +21,7 @@ module Spree
21
21
  # tool from a caller who may import one kind of record but not another.
22
22
  permission nil
23
23
  mutating!
24
+ additive!
24
25
 
25
26
  param :resource, description: 'What the file holds: products, customers, purchase_orders',
26
27
  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
- response = dispatch.call(method: :post, path: entry.api_path(:create), body: body)
25
+ path, missing = write_path(entry, :create, parent_id: parent_id)
26
+ return missing if missing
25
27
 
26
- result_for(response, entry, summary: summary(resource: resource, attributes: attributes))
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
- response = dispatch.call(method: :delete, path: entry.api_path(:destroy).sub(':id', id.to_s))
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, and the fields each one ' \
9
- 'can be filtered and sorted by. Call this before search_resources ' \
10
- 'if you are unsure which filter to use.'
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
- { resources: entries.map { |entry| describe(entry) } }
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
- def describe(entry)
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
- path = entry.api_path(:show)
25
- return { error: "#{entry.key} cannot be fetched one at a time." } if path.blank? || path.include?('/:') && path.count(':') > 1
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.sub(':id', id.to_s),
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
- verb = { create: 'created', update: 'updated', destroy: 'deleted' }.fetch(action, 'changed')
104
+ "#{entry.key} cannot be #{verb_for(action)} this way — call describe_resource to see what can."
105
+ end
94
106
 
95
- "#{entry.key} cannot be #{verb} this way — call describe_resource to see what can."
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) unless entry.dispatchable?
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
- response = dispatch.call(
46
- method: :get,
47
- path: entry.api_path(:index),
48
- params: query_for(applied, sort, limit)
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
- error: "#{entry.key} cannot be searched on its own.",
150
- available: ResourceMap.available_for(context).select(&:dispatchable?).map(&:key).sort
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