spree_core 6.0.0.rc2 → 6.0.0.rc4

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 (118) hide show
  1. checksums.yaml +4 -4
  2. data/app/mailers/spree/email_template_mailer.rb +5 -2
  3. data/app/models/concerns/spree/has_external_references.rb +7 -6
  4. data/app/models/concerns/spree/preference_schema/json_schema.rb +8 -1
  5. data/app/models/concerns/spree/purchase/taxation.rb +44 -3
  6. data/app/models/concerns/spree/translatable_resource.rb +49 -0
  7. data/app/models/spree/cart.rb +1 -0
  8. data/app/models/spree/delivery_rate.rb +12 -4
  9. data/app/models/spree/delivery_rate_provider/base.rb +1 -1
  10. data/app/models/spree/delivery_rate_provider/estimate.rb +5 -4
  11. data/app/models/spree/fulfillment.rb +1 -0
  12. data/app/models/spree/gateway/bogus.rb +7 -0
  13. data/app/models/spree/line_item.rb +3 -8
  14. data/app/models/spree/oauth_access_grant.rb +11 -0
  15. data/app/models/spree/oauth_access_token.rb +16 -0
  16. data/app/models/spree/oauth_application.rb +97 -0
  17. data/app/models/spree/oauth_consent_request.rb +23 -0
  18. data/app/models/spree/oauth_protected_resource.rb +21 -0
  19. data/app/models/spree/oauth_redirect.rb +12 -0
  20. data/app/models/spree/option_type.rb +5 -0
  21. data/app/models/spree/option_value.rb +5 -0
  22. data/app/models/spree/order.rb +5 -1
  23. data/app/models/spree/payment.rb +14 -2
  24. data/app/models/spree/payment_capture_event.rb +2 -0
  25. data/app/models/spree/policy.rb +6 -0
  26. data/app/models/spree/price.rb +16 -33
  27. data/app/models/spree/price_rule.rb +3 -16
  28. data/app/models/spree/price_rules/market_rule.rb +0 -6
  29. data/app/models/spree/pricing_provider/base.rb +1 -1
  30. data/app/models/spree/product.rb +1 -1
  31. data/app/models/spree/purchase_order_item.rb +1 -1
  32. data/app/models/spree/return.rb +1 -0
  33. data/app/models/spree/search_provider/database.rb +1 -1
  34. data/app/models/spree/stock/estimator.rb +4 -20
  35. data/app/models/spree/store.rb +8 -0
  36. data/app/models/spree/tax_exemption/item_override.rb +14 -0
  37. data/app/models/spree/tax_exemption.rb +26 -0
  38. data/app/models/spree/tax_exemption_certificate.rb +13 -2
  39. data/app/models/spree/tax_exemption_reason.rb +62 -0
  40. data/app/models/spree/tax_provider/base.rb +24 -4
  41. data/app/models/spree/tax_provider/internal.rb +36 -29
  42. data/app/models/spree/tax_rate.rb +9 -35
  43. data/app/models/spree/translations/batch.rb +14 -4
  44. data/app/services/spree/carts/price_items.rb +2 -8
  45. data/app/services/spree/carts/split_by_seller.rb +6 -1
  46. data/app/services/spree/carts/update.rb +34 -7
  47. data/app/services/spree/email_templates/preview.rb +24 -3
  48. data/app/services/spree/seeds/store_resources.rb +1 -0
  49. data/app/services/spree/seeds/tax_exemption_reasons.rb +28 -0
  50. data/app/services/spree/tax/resolve_exemptions.rb +11 -2
  51. data/app/services/spree/update_check.rb +8 -1
  52. data/app/tools/spree/agent_tools/create_export.rb +116 -0
  53. data/app/tools/spree/agent_tools/create_import.rb +117 -0
  54. data/app/tools/spree/agent_tools/create_media.rb +81 -0
  55. data/app/tools/spree/agent_tools/create_resource.rb +36 -0
  56. data/app/tools/spree/agent_tools/curate_products.rb +90 -0
  57. data/app/tools/spree/agent_tools/delete_resource.rb +28 -0
  58. data/app/tools/spree/agent_tools/describe_import_mapping.rb +73 -0
  59. data/app/tools/spree/agent_tools/describe_reporting.rb +39 -0
  60. data/app/tools/spree/agent_tools/describe_resource.rb +81 -0
  61. data/app/tools/spree/agent_tools/get_import_status.rb +71 -0
  62. data/app/tools/spree/agent_tools/get_resource.rb +97 -0
  63. data/app/tools/spree/agent_tools/import_tool.rb +80 -0
  64. data/app/tools/spree/agent_tools/propose_import_mapping.rb +138 -0
  65. data/app/tools/spree/agent_tools/query_report.rb +124 -0
  66. data/app/tools/spree/agent_tools/resource_write.rb +99 -0
  67. data/app/tools/spree/agent_tools/search_resources.rb +155 -0
  68. data/app/tools/spree/agent_tools/set_stock.rb +52 -0
  69. data/app/tools/spree/agent_tools/update_resource.rb +42 -0
  70. data/app/tools/spree/agent_tools/upload_file.rb +104 -0
  71. data/app/workflows/spree/carts/complete.rb +115 -2
  72. data/app/workflows/spree/catalogs/set_price_list.rb +2 -2
  73. data/app/workflows/spree/orders/cancel.rb +8 -0
  74. data/app/workflows/spree/orders/complete.rb +8 -0
  75. data/app/{services → workflows}/spree/orders/create.rb +9 -9
  76. data/app/workflows/spree/purchase_orders/create.rb +3 -0
  77. data/app/workflows/spree/returns/refund.rb +4 -0
  78. data/app/{services → workflows}/spree/stock_levels/bulk_upsert.rb +49 -7
  79. data/app/workflows/spree/variants/create.rb +7 -4
  80. data/config/locales/en.yml +14 -0
  81. data/db/migrate/20250902143122_fix_policies_store_association.rb +6 -4
  82. data/db/migrate/20260515000001_add_store_id_to_spree_newsletter_subscribers.rb +11 -2
  83. data/db/migrate/20260904100001_add_applied_tax_exemptions_to_spree_orders.rb +16 -0
  84. data/db/migrate/20260914230000_add_store_to_spree_option_types.rb +12 -2
  85. data/db/migrate/20261005120000_create_spree_oauth_tables.rb +67 -0
  86. data/db/migrate/20261016000001_remove_default_from_spree_tax_rates_included_in_price.rb +9 -0
  87. data/db/migrate/20261016100001_create_spree_tax_exemption_reasons.rb +21 -0
  88. data/db/migrate/20261017100001_add_tax_exemption_reason_to_spree_tax_exemption_certificates.rb +9 -0
  89. data/db/sample_data/orders.rb +1 -1
  90. data/lib/spree/agent_tool.rb +188 -0
  91. data/lib/spree/agent_tools/api_dispatch.rb +204 -0
  92. data/lib/spree/agent_tools/context.rb +191 -0
  93. data/lib/spree/agent_tools/default_catalog.rb +180 -0
  94. data/lib/spree/agent_tools/membership_map.rb +61 -0
  95. data/lib/spree/agent_tools/record_summary.rb +219 -0
  96. data/lib/spree/agent_tools/registry.rb +131 -0
  97. data/lib/spree/agent_tools/resource_map.rb +352 -0
  98. data/lib/spree/agent_tools/workflow_endpoints.rb +88 -0
  99. data/lib/spree/agent_tools/workflow_schema.rb +279 -0
  100. data/lib/spree/agent_tools/workflow_tool.rb +654 -0
  101. data/lib/spree/agent_tools.rb +24 -0
  102. data/lib/spree/core/configuration.rb +1 -1
  103. data/lib/spree/core/controller_helpers/store.rb +3 -13
  104. data/lib/spree/core/dependencies.rb +9 -0
  105. data/lib/spree/core/engine.rb +35 -1
  106. data/lib/spree/core/model_extensions.rb +111 -0
  107. data/lib/spree/core/permissions/default_catalog.rb +6 -1
  108. data/lib/spree/core/pricing/price_resolution.rb +4 -4
  109. data/lib/spree/core/version.rb +1 -1
  110. data/lib/spree/core.rb +27 -2
  111. data/lib/spree/service_module.rb +11 -0
  112. data/lib/spree/testing_support/factories/tax_exemption_certificate_factory.rb +13 -1
  113. data/lib/spree/testing_support/factories/tax_exemption_reason_factory.rb +8 -0
  114. data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +38 -0
  115. data/lib/tasks/markets.rake +47 -0
  116. data/lib/tasks/tax_exemption_reasons.rake +48 -0
  117. metadata +67 -7
  118. data/app/models/concerns/spree/vat_price_calculation.rb +0 -99
@@ -0,0 +1,36 @@
1
+ module Spree
2
+ module AgentTools
3
+ # Creates a record, through the Admin API's own create operation.
4
+ class CreateResource < Spree::AgentTools::ResourceWrite
5
+ tool_name 'create_resource'
6
+ description 'Create a record — a product with its variants, an order with its line ' \
7
+ 'items, a market, channel, payment method and so on. Send the whole record ' \
8
+ 'in one call: a list-valued attribute takes complete rows, so a product\'s ' \
9
+ 'variants carry their own options, prices and stock levels. Call ' \
10
+ 'describe_resource first — its nested_attributes says what fits inside each ' \
11
+ 'list. Records with an action of their own (cancelling, approving) have a ' \
12
+ 'tool named for it.'
13
+
14
+ param :resource, description: 'Resource key — call describe_resource for the list', required: true
15
+ param :attributes, type: :object, description: 'Attributes to set on the new record', required: true
16
+
17
+ def call(resource:, attributes: {})
18
+ entry, refusal = writable_entry(resource, :create)
19
+ return refusal if refusal
20
+
21
+ body, rejection = body_for(entry, attributes)
22
+ return rejection if rejection
23
+
24
+ response = dispatch.call(method: :post, path: entry.api_path(:create), body: body)
25
+
26
+ result_for(response, entry, summary: summary(resource: resource, attributes: attributes))
27
+ end
28
+
29
+ def summary(arguments)
30
+ named = arguments[:attributes].to_h.values_at('name', :name, 'number', :number).compact.first
31
+
32
+ "Create #{arguments[:resource].to_s.singularize.tr('_', ' ')} #{named}".strip
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,90 @@
1
+ module Spree
2
+ module AgentTools
3
+ # Puts products into a category, collection, catalog or price list, and
4
+ # takes them out again — through the Admin API's own nested products
5
+ # endpoint, which every curating parent exposes the same way.
6
+ #
7
+ # Merchandising is largely membership, so this is the write a merchant
8
+ # asks for most. One tool over every parent, because the endpoint is
9
+ # uniform and the registry derives which parents have it.
10
+ class CurateProducts < Spree::AgentTool
11
+ tool_name 'curate_products'
12
+ description 'Add products to a category, collection, catalog or price list, or remove ' \
13
+ 'them. Find the parent and the products with search_resources first and pass ' \
14
+ 'their ids. The result says how many memberships actually changed.'
15
+ # Gated per parent in `call`: curating a category needs
16
+ # write_categories and a price list write_products.
17
+ permission nil
18
+ mutating!
19
+
20
+ param :target, description: 'What to curate: category, collection, catalog or price_list',
21
+ required: true
22
+ param :id, description: "The parent's prefixed id, as it appears in search results",
23
+ required: true
24
+ param :products, type: :array, items: :string,
25
+ description: 'Prefixed ids of the products to add or remove', required: true
26
+ param :operation, description: '"add" (the default) or "remove"'
27
+
28
+ # Offered only to a caller who can curate at least one parent.
29
+ def permitted?
30
+ MembershipMap.all.any? { |entry| context.holds?(entry.permission) }
31
+ end
32
+
33
+ def call(target:, id:, products:, operation: nil)
34
+ entry = MembershipMap.find(target)
35
+ return unknown_target(target) if entry.nil?
36
+ return { error: "#{entry.target} cannot be curated." } if entry.api_path.blank?
37
+
38
+ ids = Array(products).map(&:to_s).reject(&:blank?)
39
+ return { error: 'Give at least one product.' } if ids.empty?
40
+
41
+ removing = operation.to_s.casecmp('remove').zero?
42
+ response = dispatch.call(
43
+ method: removing ? :delete : :post,
44
+ path: entry.api_path.sub(/:\w+_id/, id.to_s),
45
+ body: { product_ids: ids }
46
+ )
47
+ return { error: response.error_message } unless response.success?
48
+
49
+ result(entry, id, response.body, removing)
50
+ end
51
+
52
+ def summary(arguments)
53
+ count = Array(arguments[:products]).size
54
+ noun = 'product'.pluralize(count)
55
+ parent = arguments[:target].to_s.tr('_', ' ')
56
+
57
+ if arguments[:operation].to_s.casecmp('remove').zero?
58
+ "Remove #{count} #{noun} from #{parent} #{arguments[:id]}"
59
+ else
60
+ "Add #{count} #{noun} to #{parent} #{arguments[:id]}"
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def dispatch
67
+ @dispatch ||= ApiDispatch.new(context)
68
+ end
69
+
70
+ # The endpoint counts what actually changed — an id already a member
71
+ # changes nothing, and one naming no product in this store is ignored.
72
+ def result(entry, id, body, removing)
73
+ counted = body.is_a?(Hash) ? body : {}
74
+
75
+ {
76
+ ok: true,
77
+ target: entry.target,
78
+ id: id,
79
+ removing ? :removed : :added => counted['removed_count'] || counted['added_count'] || 0
80
+ }
81
+ end
82
+
83
+ def unknown_target(target)
84
+ allowed = MembershipMap.all.select { |entry| context.holds?(entry.permission) }.map(&:target).sort
85
+
86
+ { error: "Cannot curate #{target.inspect}.", available_targets: allowed }
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,28 @@
1
+ module Spree
2
+ module AgentTools
3
+ # Removes a record, through the Admin API's own destroy operation —
4
+ # which soft-deletes, archives or refuses exactly as the dashboard does.
5
+ class DeleteResource < Spree::AgentTools::ResourceWrite
6
+ tool_name 'delete_resource'
7
+ description 'Delete a record. The endpoint decides what deleting means for it — some ' \
8
+ 'resources archive, some refuse while they are in use.'
9
+
10
+ param :resource, description: 'Resource key — call describe_resource for the list', required: true
11
+ param :id, description: 'The record id as shown in search results', required: true
12
+
13
+ def call(resource:, id:)
14
+ entry, refusal = writable_entry(resource, :destroy)
15
+ return refusal if refusal
16
+
17
+ response = dispatch.call(method: :delete, path: entry.api_path(:destroy).sub(':id', id.to_s))
18
+ return { error: response.error_message } unless response.success?
19
+
20
+ { summary: summary(resource: resource, id: id) }
21
+ end
22
+
23
+ def summary(arguments)
24
+ "Delete #{arguments[:resource].to_s.singularize.tr('_', ' ')} #{arguments[:id]}"
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,73 @@
1
+ module Spree
2
+ module AgentTools
3
+ # What an import still needs mapped: the file's own column headings, the
4
+ # Spree fields available, and which of those are required.
5
+ #
6
+ # This is the half of the mapping problem a computer cannot do. Deciding
7
+ # that a column headed "Retail Price (inc VAT)" is the price field is
8
+ # judgement about messy human spreadsheets — exactly what the model is
9
+ # for — but it can only judge from the real vocabulary, so this hands it
10
+ # the schema rather than letting it guess field names.
11
+ class DescribeImportMapping < Spree::AgentTools::ImportTool
12
+ tool_name 'describe_import_mapping'
13
+ description "List an import's unmapped file columns and the Spree fields they can " \
14
+ 'map onto, so you can propose a mapping. Call this before ' \
15
+ 'propose_import_mapping.'
16
+ # Gated per import in `call`, on the scope of the resource being
17
+ # imported: the response carries a row of the uploaded file.
18
+ permission nil
19
+
20
+ param :id, description: 'Import id or number. Omit for the most recent import awaiting mapping.',
21
+ required: false
22
+
23
+ def call(id: nil)
24
+ import = find_import(id, status: 'mapping')
25
+ return { error: 'No import is waiting to be mapped.' } if import.nil?
26
+
27
+ refusal = unauthorized_import(import)
28
+ return refusal if refusal
29
+
30
+ {
31
+ id: import.prefixed_id,
32
+ number: import.number,
33
+ status: import.status,
34
+ file_columns: import.csv_headers,
35
+ unmapped_file_columns: import.unmapped_file_columns,
36
+ spree_fields: describe_fields(import),
37
+ already_mapped: current_mapping(import),
38
+ sample_row: import.sample_row
39
+ }.compact
40
+ end
41
+
42
+ def summary(arguments)
43
+ 'Read the import file layout'
44
+ end
45
+
46
+ private
47
+
48
+ # Name, human label and whether it must be filled — the model needs all
49
+ # three to explain its choices and to know what it cannot leave out.
50
+ #
51
+ # Read from `schema_fields`, which is what a mapping is validated
52
+ # against: it adds the model's custom-field definitions to the schema's
53
+ # own list. Describing the schema alone would accept a custom field on
54
+ # write while never telling the model it exists.
55
+ def describe_fields(import)
56
+ Array(import.schema_fields).map do |field|
57
+ {
58
+ field: field[:name],
59
+ label: field[:label],
60
+ required: field[:required] == true
61
+ }
62
+ end
63
+ end
64
+
65
+ def current_mapping(import)
66
+ mapped = import.mappings.mapped
67
+ return if mapped.empty?
68
+
69
+ mapped.to_h { |mapping| [mapping.schema_field, mapping.file_column] }
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,39 @@
1
+ module Spree
2
+ module AgentTools
3
+ # Tells the model which metrics and dimensions this store can be asked
4
+ # about, so it composes a query from the real vocabulary rather than
5
+ # guessing names and reading errors back to the merchant.
6
+ class DescribeReporting < Spree::AgentTool
7
+ tool_name 'describe_reporting'
8
+ description 'List the reporting metrics and dimensions available, and which dimensions ' \
9
+ 'each metric can be grouped by. Call this before query_report.'
10
+ permission 'read_reports'
11
+
12
+ def call
13
+ Spree::Reporting::Schema.new(
14
+ store: context.store,
15
+ allowed: ->(member) { allowed?(member) }
16
+ ).to_h
17
+ end
18
+
19
+ def summary(_arguments)
20
+ 'List the reporting metrics and dimensions'
21
+ end
22
+
23
+ private
24
+
25
+ # Filtered to what this caller may reference, so the model is never
26
+ # offered a member its next query would be refused for. The same
27
+ # predicate the reporting schema endpoint applies, in the same two
28
+ # flavours: a subject through CanCanCan for an admin, a key scope for a
29
+ # secret key.
30
+ def allowed?(member)
31
+ if context.user_principal?
32
+ member.subject.nil? || context.can?(:read, member.subject.call)
33
+ else
34
+ context.holds?(member.key_scope)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,81 @@
1
+ module Spree
2
+ module AgentTools
3
+ # Tells the model which resources exist and what they can be filtered by,
4
+ # so it discovers the real vocabulary instead of guessing field names and
5
+ # producing errors the merchant has to read.
6
+ class DescribeResource < Spree::AgentTool
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.'
11
+ # Deliberately ungated: it exposes only field names, and the resources it
12
+ # lists are already filtered to what this admin may read.
13
+ permission nil
14
+
15
+ param :resource,
16
+ description: 'Optional resource key to describe. Omit to list them all.',
17
+ required: false
18
+
19
+ def call(resource: nil)
20
+ entries = Spree::AgentTools::ResourceMap.available_for(context)
21
+ entries = entries.select { |entry| entry.key == resource.to_s } if resource.present?
22
+
23
+ if entries.empty?
24
+ return { error: "Unknown or unavailable resource #{resource.inspect}",
25
+ available: Spree::AgentTools::ResourceMap.available_for(context).map(&:key) }
26
+ end
27
+
28
+ { resources: entries.map { |entry| describe(entry) } }
29
+ end
30
+
31
+ private
32
+
33
+ # Every dispatchable resource reports its attributes now: the generic
34
+ # writes reach the controller, which runs whatever workflow it
35
+ # declares, so there is no longer a class of resource they refuse.
36
+ def nested_shapes_for(entry)
37
+ return if writable_attributes_for(entry).blank?
38
+
39
+ entry.nested_collection_shapes.presence
40
+ end
41
+
42
+ def writable_attributes_for(entry)
43
+ return unless entry.api_path(:update).present? || entry.api_path(:create).present?
44
+ return unless entry.write_permission.present? && context.permitted?(entry.write_permission)
45
+
46
+ entry.writable_attribute_names.presence
47
+ end
48
+
49
+ def describe(entry)
50
+ {
51
+ resource: entry.key,
52
+ filterable_fields: entry.filterable_fields,
53
+ # What a generic write accepts, so the model learns the schema here
54
+ # rather than by sending an invalid write and reading the refusal —
55
+ # which is what create_resource and update_resource tell it to do.
56
+ # Offered only where the caller could actually write.
57
+ writable_attributes: writable_attributes_for(entry),
58
+ # What goes inside a list-valued attribute. Without it a model sees
59
+ # `variants` and has no idea a whole variant fits there, so it
60
+ # creates the product and then adds each variant separately.
61
+ nested_attributes: nested_shapes_for(entry),
62
+ # What get_resource can be asked to include. A record arrives
63
+ # without its associations, so a model that does not know these
64
+ # names cannot ask for the one it needs.
65
+ expandable_associations: entry.expandable_associations.presence,
66
+ # The action tool for a record that has one of its own — cancel,
67
+ # complete, approve. Ordinary attribute changes go through
68
+ # update_resource whatever the controller does internally.
69
+ created_by_tool: entry.create_workflow_key&.tr('.', '_'),
70
+ updated_by_tool: entry.update_workflow_key&.tr('.', '_'),
71
+ # Named queries answer things no column can — stock levels live
72
+ # across warehouses, so "out of stock" is a scope, not a field.
73
+ filterable_scopes: entry.filterable_scopes,
74
+ usage: 'Attribute filters use Ransack predicates, e.g. name_cont, created_at_gteq, ' \
75
+ 'status_eq. Named scopes are passed as a bare key set to true, e.g. ' \
76
+ '{"out_of_stock": true}.'
77
+ }.compact
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,71 @@
1
+ module Spree
2
+ module AgentTools
3
+ # Where an import has got to, and what went wrong with it.
4
+ #
5
+ # A merchant who uploaded a file wants one of two answers: is it done, or
6
+ # why did those rows fail? Both are tedious to assemble from the imports
7
+ # page and easy to say in a sentence.
8
+ class GetImportStatus < Spree::AgentTools::ImportTool
9
+ # How many failed rows are read to work out why they failed.
10
+ SCANNED_ROW_LIMIT = 200
11
+ FAILED_ROW_SAMPLE = 5
12
+
13
+ tool_name 'get_import_status'
14
+ description 'Check how an import is going: its state, how many rows succeeded ' \
15
+ 'or failed, and why the failures failed.'
16
+ # Gated per import in `call`, on the scope of the resource being
17
+ # imported: the failed rows carry the uploaded data.
18
+ permission nil
19
+
20
+ param :id, description: 'Import id or number. Omit for the most recent import.',
21
+ required: false
22
+
23
+ def call(id: nil)
24
+ import = find_import(id)
25
+ return { error: 'No import found.' } if import.nil?
26
+
27
+ refusal = unauthorized_import(import)
28
+ return refusal if refusal
29
+
30
+ {
31
+ id: import.prefixed_id,
32
+ number: import.number,
33
+ type: import_kind(import),
34
+ status: import.status,
35
+ rows: import.rows_status_counts,
36
+ # The reasons, not the rows: a merchant fixes a spreadsheet by
37
+ # learning "12 rows have no SKU", not by reading 12 rows back.
38
+ #
39
+ failure_reasons: failure_reasons(import)
40
+ }.compact
41
+ end
42
+
43
+ def summary(arguments)
44
+ arguments[:id].present? ? "Check import #{arguments[:id]}" : 'Check the latest import'
45
+ end
46
+
47
+ private
48
+
49
+ # Grouped so the assistant can say "12 rows failed because the SKU was
50
+ # missing" rather than reciting every row.
51
+ #
52
+ # Counted over a sample rather than the whole failure set, because an
53
+ # import that failed wholesale has as many rows as the file did. The
54
+ # counts therefore say `rows_in_sample`, not `rows`: reporting a sample
55
+ # as a total would have the assistant tell the merchant twelve rows
56
+ # failed when twelve hundred did.
57
+ def failure_reasons(import)
58
+ failed = import.rows.failed.limit(SCANNED_ROW_LIMIT).to_a
59
+ return if failed.empty?
60
+
61
+ reasons = failed.filter_map { |row| row.validation_errors.presence }
62
+ .tally
63
+ .sort_by { |_reason, count| -count }
64
+ .first(FAILED_ROW_SAMPLE)
65
+ .map { |reason, count| { reason: reason, rows_in_sample: count } }
66
+
67
+ { scanned: failed.size, total_failed: import.rows.failed.count, reasons: reasons }
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,97 @@
1
+ module Spree
2
+ module AgentTools
3
+ # Fetches one record in full, through the Admin API's own show operation,
4
+ # for when the summary from a search is not enough to answer the question.
5
+ class GetResource < Spree::AgentTool
6
+ tool_name 'get_resource'
7
+ description 'Fetch one record in full by its id, e.g. prod_86Rf07xd4z or an order ' \
8
+ 'number. A record\'s associations are left out unless asked for: name them ' \
9
+ 'in expand to see a product\'s variants or an order\'s line items. ' \
10
+ 'describe_resource lists which ones a resource offers.'
11
+ permission nil
12
+
13
+ param :resource, description: 'Resource key — call describe_resource for the list', required: true
14
+ param :id, description: 'The record id as shown in search results', required: true
15
+ param :expand, type: :array, items: :string,
16
+ description: "Associations to include, e.g. [\"variants\"] on a product"
17
+
18
+ def call(resource:, id:, expand: nil)
19
+ entry = ResourceMap.find(resource)
20
+ return unknown_resource(resource) if entry.nil?
21
+ return { error: "You do not have permission to read #{entry.key}." } unless
22
+ context.permitted?(entry.permission)
23
+
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
26
+
27
+ requested, refusal = expansions(entry, expand)
28
+ return refusal if refusal
29
+
30
+ response = dispatch.call(method: :get, path: path.sub(':id', id.to_s),
31
+ params: requested.any? ? { expand: requested.join(',') } : {})
32
+ return { error: not_found(entry, id, response) } unless response.success?
33
+
34
+ attributes = sanitize(response.data, entry)
35
+ # An empty hash would be reported as a successful lookup of a record
36
+ # with no fields, which is worse than saying the read failed.
37
+ return { error: "Could not read this #{entry.key.singularize}." } if attributes.blank?
38
+
39
+ {
40
+ resource: entry.key,
41
+ # The full record for the model to answer from, plus the same
42
+ # compact row `search_resources` returns so the panel can show it
43
+ # as something the merchant can click through to.
44
+ record: attributes,
45
+ records: [RecordSummary.from_payload(entry: entry, payload: attributes)],
46
+ count: 1,
47
+ total: 1
48
+ }
49
+ end
50
+
51
+ # An association the serializer does not offer is named back rather than
52
+ # ignored, so a model that guessed learns the real list instead of
53
+ # reading a record that silently lacks what it asked for.
54
+ def expansions(entry, expand)
55
+ requested = Array(expand).map(&:to_s).reject(&:blank?)
56
+ return [[], nil] if requested.empty?
57
+
58
+ offered = entry.expandable_associations
59
+ unknown = requested - offered
60
+ return [requested, nil] if unknown.empty?
61
+
62
+ [nil, { error: "#{entry.key} cannot expand #{unknown.to_sentence}.",
63
+ expandable: offered }]
64
+ end
65
+
66
+ # Named, not identified: "Look up Rotary Shaver 9000" is what the
67
+ # merchant is watching happen. A prefixed id is our plumbing.
68
+ def summary(arguments)
69
+ "Look up #{arguments[:id]}"
70
+ end
71
+
72
+ private
73
+
74
+ def dispatch
75
+ @dispatch ||= ApiDispatch.new(context)
76
+ end
77
+
78
+ # Sanitized like every other outbound record: this is the one tool that
79
+ # emits a whole serializer hash, so without the filter a serializer that
80
+ # gains a token field would send it straight to the AI vendor — the
81
+ # exact case the filter exists for.
82
+ def sanitize(payload, entry)
83
+ return {} unless payload.is_a?(Hash)
84
+
85
+ RecordSummary.sanitize(payload.stringify_keys, entry.key)
86
+ end
87
+
88
+ # A record in another store and one that does not exist answer the same
89
+ # way, so a probe learns nothing from the difference.
90
+ def not_found(entry, id, response)
91
+ return response.error_message if response.status != 404
92
+
93
+ "No #{entry.key.singularize} found for #{id.inspect}"
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,80 @@
1
+ module Spree
2
+ module AgentTools
3
+ # Shared behaviour for the tools that walk a CSV import through its
4
+ # mapping step.
5
+ #
6
+ # They all have to find an import the same way — store-scoped, addressed
7
+ # by whichever of its prefixed id or its number the merchant said aloud —
8
+ # and getting that lookup right is the whole of their tenancy story, so it
9
+ # lives in one place rather than three.
10
+ class ImportTool < Spree::AgentTool
11
+ protected
12
+
13
+ # The import the caller named, or the most recent one matching `scope`
14
+ # when they named none.
15
+ #
16
+ # @param id [String, nil] a prefixed id or an import number
17
+ # @param scope [Symbol, nil] narrows the fallback, e.g. `:mapping`
18
+ # @return [Spree::Import, nil]
19
+ def find_import(id, status: nil)
20
+ relation = imports_scope
21
+ return if relation.nil?
22
+
23
+ return latest_import(relation, status) if id.blank?
24
+
25
+ by_prefixed_id(relation, id) || relation.find_by(number: id)
26
+ end
27
+
28
+ # The resource map is derived from the admin controllers, so an
29
+ # installation without them has no imports entry — and a nil here must
30
+ # read as "nothing found", never as a crash inside a rescue.
31
+ #
32
+ # @return [ActiveRecord::Relation, nil]
33
+ def imports_scope
34
+ ResourceMap.find('imports')&.scope_for(context)
35
+ end
36
+
37
+ # Whether the caller may see this import's contents.
38
+ #
39
+ # Gated on the imported resource's **write** scope, which is what the
40
+ # Admin API's own ImportsController requires for every import action
41
+ # including its reads. An import is not the resource: it is whatever
42
+ # someone staged, so a sample row is a real customer's name and address
43
+ # that may never be saved, and a failure reason quotes the value that
44
+ # failed. Reading a customer list is not permission to read a file
45
+ # somebody uploaded, and `read_settings` certainly is not.
46
+ #
47
+ # @param import [Spree::Import]
48
+ # @return [Hash, nil] an error result to return, or nil when allowed
49
+ def unauthorized_import(import)
50
+ scope = import.class.try(:required_scope)
51
+ return if scope.blank?
52
+ return if context.holds?("write_#{scope}")
53
+
54
+ { error: "You do not have permission to read a #{import_kind(import)} import." }
55
+ end
56
+
57
+ # What kind of import this is, in the merchant's words.
58
+ #
59
+ # @return [String]
60
+ def import_kind(import)
61
+ import.class.name.demodulize.underscore.tr('_', ' ')
62
+ end
63
+
64
+ private
65
+
66
+ def latest_import(relation, status)
67
+ relation = relation.where(status: status) if status.present?
68
+ relation.order(created_at: :desc).first
69
+ end
70
+
71
+ # A value that is not a prefixed id at all raises rather than missing,
72
+ # which is expected here: the merchant may well have said a number.
73
+ def by_prefixed_id(relation, id)
74
+ relation.find_by_prefix_id(id)
75
+ rescue ArgumentError, NoMethodError
76
+ nil
77
+ end
78
+ end
79
+ end
80
+ end