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,138 @@
1
+ module Spree
2
+ module AgentTools
3
+ # Applies a column mapping to an import, and starts it if the mapping is
4
+ # complete.
5
+ #
6
+ # Mutating, and deliberately so. Mapping decides what every row in the
7
+ # file becomes: get "Cost" onto `price` instead of `cost_price` and a
8
+ # merchant has repriced their whole catalogue in one click. The merchant
9
+ # sees the proposed pairing and approves it before anything is written.
10
+ class ProposeImportMapping < Spree::AgentTools::ImportTool
11
+ tool_name 'propose_import_mapping'
12
+ description 'Map the columns of an uploaded import file onto Spree fields. ' \
13
+ 'Call describe_import_mapping first to see the real column headings ' \
14
+ 'and field names. Pass a mapping of spree_field => file_column; ' \
15
+ 'omit a field to leave it unmapped. The merchant approves before ' \
16
+ 'anything is applied.'
17
+ # The Admin API gates every import action on the write scope of the
18
+ # resource being imported — `write_products` for a product import —
19
+ # never on a settings key. Mirrored here, per-import, in `call`.
20
+ # Gated per import kind in `call`, not here: a caller who may import
21
+ # customers but not products must still be offered the tool.
22
+ permission nil
23
+ mutating!
24
+
25
+ param :id, description: 'Import id or number', required: true
26
+ param :mapping, type: :object,
27
+ description: 'Spree field to file column, e.g. {"name": "Product Title"}',
28
+ required: true
29
+
30
+ def call(id:, mapping:)
31
+ import = find_import(id)
32
+ return { error: "No import found for #{id.inspect}" } if import.nil?
33
+
34
+ # Per-import, because a customer import needs `write_customers`
35
+ # while a product import needs `write_products`.
36
+ required = "write_#{import.class.required_scope}"
37
+ unless context.holds?(required)
38
+ return { error: "You do not have permission to run a #{import_kind(import)} import." }
39
+ end
40
+
41
+ refusal = unauthorized(:update, import)
42
+ return refusal if refusal
43
+
44
+ return { error: 'This import is not waiting to be mapped.' } unless import.mapping?
45
+
46
+ pairs = sanitize(import, mapping)
47
+ return unknown_fields(import, mapping) if pairs.empty?
48
+
49
+ # A pair naming a field or column that does not exist is dropped here
50
+ # rather than refusing the whole proposal — the valid half is usually
51
+ # most of it, and a model that misspelled one heading should not have
52
+ # to resend forty. It is named back, so the model can correct that one
53
+ # and never believes it was applied.
54
+ rejected = mapping.to_h.keys.map(&:to_s) - pairs.keys
55
+
56
+ applied = apply!(import, pairs)
57
+ start_if_ready(import)
58
+
59
+ {
60
+ ok: true,
61
+ id: import.prefixed_id,
62
+ # Only what was actually written. Reporting a field as mapped when
63
+ # its mapping row did not exist would have the model tell the
64
+ # merchant a column was handled when it was not.
65
+ mapped: applied,
66
+ # Named back so the model knows exactly what did not land, and why:
67
+ # `rejected` did not exist on this import, `unmapped` exists but has
68
+ # no mapping row to write to.
69
+ rejected: rejected.presence,
70
+ unmapped: (pairs.keys - applied.keys).presence,
71
+ status: import.reload.status,
72
+ # `mapped_fields` returns mapping records, not names.
73
+ still_missing: import.required_fields - import.mappings.mapped.pluck(:schema_field)
74
+ }
75
+ end
76
+
77
+ # The merchant judges the pairing itself, so the card spells it out
78
+ # rather than saying "apply a mapping".
79
+ def summary(arguments)
80
+ pairs = (arguments[:mapping] || {}).map { |field, column| "#{column} → #{field}" }
81
+ return 'Apply an import mapping' if pairs.empty?
82
+
83
+ "Map #{pairs.join(', ')}"
84
+ end
85
+
86
+ private
87
+
88
+ # Only fields the schema declares and columns the file actually has —
89
+ # a hallucinated pairing must not reach the database.
90
+ def sanitize(import, mapping)
91
+ # `schema_fields` returns {name:, label:, required:} hashes, not names.
92
+ fields = import.schema_fields.map { |field| field[:name].to_s }
93
+ columns = import.csv_headers
94
+
95
+ (mapping || {}).to_h.filter_map do |field, column|
96
+ next unless fields.include?(field.to_s)
97
+ next unless column.blank? || columns.include?(column.to_s)
98
+
99
+ [field.to_s, column.presence&.to_s]
100
+ end.to_h
101
+ end
102
+
103
+ # Loaded in one query rather than one per field: a product import
104
+ # declares forty-five of them, and a model proposing a full mapping
105
+ # sends most at once.
106
+ #
107
+ # @return [Hash] only the pairs that had a mapping row to write to
108
+ def apply!(import, pairs)
109
+ rows = import.mappings.where(schema_field: pairs.keys).index_by(&:schema_field)
110
+ applied = pairs.select { |field, _column| rows.key?(field) }
111
+
112
+ Spree::ImportMapping.transaction do
113
+ applied.each { |field, column| rows.fetch(field).update!(file_column: column) }
114
+ end
115
+ import.mappings.reload
116
+
117
+ applied
118
+ end
119
+
120
+ # Starting the import is the same transition the dashboard's own mapping
121
+ # step makes, and only once every required field is satisfied. Through
122
+ # the workflow rather than the deprecated model method it replaced.
123
+ def start_if_ready(import)
124
+ return unless import.mapping_done?
125
+
126
+ Spree.import_complete_mapping_workflow.call(import: import)
127
+ end
128
+
129
+ def unknown_fields(import, mapping)
130
+ {
131
+ error: 'None of those fields or columns exist on this import.',
132
+ spree_fields: import.schema_fields.map { |field| field[:name] },
133
+ file_columns: import.csv_headers
134
+ }
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,124 @@
1
+ module Spree
2
+ module AgentTools
3
+ # Answers a reporting question — revenue by month, units by product,
4
+ # orders by channel — against the same semantic registry the dashboard
5
+ # charts read, so an agent and a chart cannot disagree about what a
6
+ # number means.
7
+ class QueryReport < Spree::AgentTool
8
+ tool_name 'query_report'
9
+ description 'Run a reporting query: pick metrics (net_sales, orders, units_sold) and ' \
10
+ 'dimensions to group them by, and get the aggregated rows. Call ' \
11
+ 'describe_reporting first for the exact names this store has.'
12
+ permission 'read_reports'
13
+
14
+ param :metrics, type: :array, items: :string,
15
+ description: 'Metric names, e.g. ["orders", "net_sales"]', required: true
16
+ param :dimensions, type: :array, items: :string,
17
+ description: 'Dimensions to group by, as named by describe_reporting ' \
18
+ '(e.g. ["completed_at", "channel"]). Omit for a single total.',
19
+ required: false
20
+ param :time_range, type: :object,
21
+ description: 'A preset such as {"preset": "last_30_days"}, or {"from": "2026-01-01", "to": "2026-03-31"}',
22
+ required: false
23
+ param :filters, type: :array, items: :object,
24
+ description: 'Dimension filters, each {"dimension": …, "op": "eq" or "in", "value": …} — ' \
25
+ 'e.g. [{"dimension": "channel", "op": "in", "value": ["web", "pos"]}]',
26
+ required: false
27
+ param :sort, description: 'Metric or dimension to sort by, prefixed with - for descending', required: false
28
+ param :limit, type: :integer, description: 'Maximum rows to return (default 50)', required: false
29
+ param :currency, description: 'Currency for money metrics; defaults to the store currency', required: false
30
+
31
+ # Rows land in the model's context verbatim, so a query that would have
32
+ # returned thousands is capped rather than truncated silently.
33
+ DEFAULT_LIMIT = 50
34
+ MAX_LIMIT = 500
35
+
36
+ def call(metrics:, dimensions: nil, time_range: nil, filters: nil, sort: nil, limit: nil, currency: nil)
37
+ query = build_query(metrics: metrics, dimensions: dimensions, time_range: time_range,
38
+ filters: filters, sort: sort, limit: limit, currency: currency)
39
+
40
+ refusal = unauthorized_members(query)
41
+ return refusal if refusal
42
+
43
+ shape(query.execute)
44
+ rescue Spree::Reporting::UnknownMember => e
45
+ { error: e.message, valid: Array(e.valid).map(&:to_s) }
46
+ rescue Spree::Reporting::InvalidQuery => e
47
+ { error: e.message }
48
+ end
49
+
50
+ def summary(arguments)
51
+ metrics = Array(arguments[:metrics]).join(', ')
52
+ dimensions = Array(arguments[:dimensions])
53
+
54
+ dimensions.any? ? "Report #{metrics} by #{dimensions.join(', ')}" : "Report #{metrics}"
55
+ end
56
+
57
+ private
58
+
59
+ def build_query(metrics:, dimensions:, time_range:, filters:, sort:, limit:, currency:)
60
+ parameters = {
61
+ 'metrics' => Array(metrics),
62
+ 'dimensions' => Array(dimensions),
63
+ 'filters' => Array(filters),
64
+ 'limit' => [(limit || DEFAULT_LIMIT).to_i, MAX_LIMIT].min
65
+ }
66
+ parameters['time_range'] = time_range if time_range.present?
67
+ parameters['sort'] = sort if sort.present?
68
+ parameters['currency'] = currency if currency.present?
69
+
70
+ Spree::Reporting::Query.new(store: context.store, params: parameters)
71
+ end
72
+
73
+ # The same per-member check the reporting endpoint makes, in the same
74
+ # two flavours: a key is checked against its scopes, an admin against
75
+ # their ability. A caller who may read orders but not products cannot
76
+ # reach the product dimension here either.
77
+ def unauthorized_members(query)
78
+ # Asked of the context for every principal, not just a key.
79
+ #
80
+ # `context.ability` is the staff member's whole role, so a token
81
+ # granted `read_reports` and `read_orders` could group by the product
82
+ # dimension its merchant never consented to. `holds?` is the narrowed
83
+ # answer: a key's scopes, or a grant's, or the role when the person is
84
+ # acting directly.
85
+ missing = query.required_key_scopes.reject { |scope| context.holds?(scope) }
86
+ return if missing.empty?
87
+
88
+ { error: "You do not have permission to report on #{missing.to_sentence.delete_prefix('read_').tr('_', ' ')}." }
89
+ end
90
+
91
+ # Totals first: most reporting questions are answered by them alone,
92
+ # and the rows are what fills a context window.
93
+ #
94
+ # Dimension keys are hydrated the same way the Admin API hydrates them.
95
+ # A grouped row holds a raw primary key, and handing a model
96
+ # `{"product": 3}` answers "which products sell best" with a number it
97
+ # cannot read — the label is the answer.
98
+ def shape(result)
99
+ hydration = Spree::Reporting::Hydration.new(result, store: context.store)
100
+ rows = Array(result.rows).map { |row| hydrate_row(row, hydration) }
101
+
102
+ {
103
+ totals: result.totals,
104
+ rows: rows,
105
+ row_count: rows.length,
106
+ meta: result.meta
107
+ }.compact
108
+ end
109
+
110
+ # Only the label and the id: the API's shape also carries slugs and
111
+ # thumbnail URLs, which cost context and answer nothing a report was
112
+ # asked.
113
+ def hydrate_row(row, hydration)
114
+ dimensions = (row[:dimensions] || {}).to_h do |name, raw|
115
+ value = hydration.value(name, raw)
116
+ [name, value.is_a?(Hash) ? value.values_at(:label, :id).compact.first : value]
117
+ end
118
+
119
+ row.merge(dimensions: dimensions)
120
+ end
121
+
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,99 @@
1
+ module Spree
2
+ module AgentTools
3
+ # Shared by the generic writes, which dispatch to the Admin API's own
4
+ # create, update and destroy operations.
5
+ #
6
+ # The controller behind each decides everything that matters: which
7
+ # attributes it accepts, whether it writes through a workflow, what the
8
+ # refusal reads like. So these three are correct for every resource, and
9
+ # the rule that a resource with a workflow refuses the generic write is
10
+ # gone — there was only ever one way to write a record, and now these
11
+ # take it.
12
+ class ResourceWrite < Spree::AgentTool
13
+ # Gated per resource in `call`, since each names its own write scope —
14
+ # so the class declares none.
15
+ permission nil
16
+ mutating!
17
+
18
+ # Offered only to a caller who can write something.
19
+ #
20
+ # The endpoint would refuse anyway, but a tool a credential can never
21
+ # use should not be named to the model: the server promises that a tool
22
+ # you cannot see is one the store has not granted.
23
+ def permitted?
24
+ ResourceMap.all.any? do |entry|
25
+ entry.write_permission.present? && context.permitted?(entry.write_permission)
26
+ end
27
+ end
28
+
29
+ protected
30
+
31
+ def dispatch
32
+ @dispatch ||= ApiDispatch.new(context)
33
+ end
34
+
35
+ # The entry, or the refusal naming why not.
36
+ #
37
+ # @return [Array(Entry, nil), Array(nil, Hash)]
38
+ def writable_entry(key, action)
39
+ entry = ResourceMap.find(key)
40
+ return [nil, unknown_resource(key)] if entry.nil?
41
+
42
+ path = entry.api_path(action)
43
+ return [nil, { error: not_writable(entry, action) }] if path.blank?
44
+
45
+ # The endpoint answers 403 for a scope the credential lacks, in its
46
+ # own words. This check only avoids offering a tool that cannot work
47
+ # — a resource the caller cannot even read.
48
+ return [nil, { error: "You do not have permission to change #{entry.key}." }] unless
49
+ context.permitted?(entry.permission)
50
+
51
+ [entry, nil]
52
+ end
53
+
54
+ # Attributes travel as the request body, so what the controller accepts
55
+ # is the only list — including the attributes an extension added, which
56
+ # the hand-written list this replaces could not see.
57
+ #
58
+ # An attribute the controller does not permit is dropped silently and
59
+ # the response is a 200, so a model would report a change that never
60
+ # happened. Named back instead, the way an unknown filter is.
61
+ #
62
+ # The list comes from the generated OpenAPI body, which is the contract
63
+ # clients already read — so an attribute the endpoint accepts and the
64
+ # document omits is a documentation bug, fixed in the integration spec
65
+ # that generates it, not worked around here.
66
+ #
67
+ # @return [Array(Hash, nil), Array(nil, Hash)]
68
+ def body_for(entry, attributes)
69
+ body = (attributes || {}).to_h.deep_transform_keys(&:to_s)
70
+ accepted = entry.writable_attribute_names
71
+ return [body, nil] if accepted.blank?
72
+
73
+ unknown = body.keys - accepted
74
+ return [body, nil] if unknown.empty?
75
+
76
+ [nil, { error: "#{entry.key} does not accept #{unknown.to_sentence}.",
77
+ accepted_attributes: accepted }]
78
+ end
79
+
80
+ def result_for(response, entry, summary:)
81
+ return { error: response.error_message } unless response.success?
82
+
83
+ payload = response.data
84
+ record = payload.is_a?(Hash) ? RecordSummary.sanitize(payload.stringify_keys, entry.key) : nil
85
+
86
+ {
87
+ summary: summary,
88
+ record: record && RecordSummary.from_payload(entry: entry, payload: record)
89
+ }.compact
90
+ end
91
+
92
+ def not_writable(entry, action)
93
+ verb = { create: 'created', update: 'updated', destroy: 'deleted' }.fetch(action, 'changed')
94
+
95
+ "#{entry.key} cannot be #{verb} this way — call describe_resource to see what can."
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,155 @@
1
+ module Spree
2
+ module AgentTools
3
+ # Searches store records, through the Admin API's own list operation.
4
+ #
5
+ # Filters, sorting, pagination, store scoping and record-level permissions
6
+ # are the endpoint's, not this tool's. That is the point: the tool used to
7
+ # call `.ransack` itself and skipped the step that decodes prefixed ids,
8
+ # so filtering by an id it had just handed the model matched nothing and
9
+ # reported it as a fact.
10
+ class SearchResources < Spree::AgentTool
11
+ DEFAULT_LIMIT = 10
12
+ MAX_LIMIT = 25
13
+
14
+ tool_name 'search_resources'
15
+ description 'Search the store. Filters are Ransack predicates ' \
16
+ '(name_cont, created_at_gteq, status_eq) or named scopes passed as a ' \
17
+ 'bare key, e.g. {"out_of_stock": true} for stock questions or ' \
18
+ '{"price_lte": 20} for price. Call describe_resource to see every ' \
19
+ 'resource, field and scope available.'
20
+ # Per-resource permissions are the endpoint's to enforce — it answers
21
+ # 403 for a scope the credential lacks. This tool is offered whenever
22
+ # the caller can read anything at all.
23
+ permission nil
24
+
25
+ param :resource, description: 'Resource key — call describe_resource for the list', required: true
26
+ param :filters, type: :object, description: 'Ransack filter hash, e.g. {"name_cont": "shirt"}'
27
+ param :sort, description: 'Sort, e.g. "created_at desc"'
28
+ param :limit, type: :integer, description: "Max records to return (default #{DEFAULT_LIMIT})"
29
+
30
+ def call(resource:, filters: nil, sort: nil, limit: nil)
31
+ entry = ResourceMap.find(resource)
32
+ return unknown_resource(resource) if entry.nil?
33
+ return not_searchable(entry) unless entry.dispatchable?
34
+ return forbidden(entry) unless context.permitted?(entry.permission)
35
+
36
+ applied = sanitize_filters(filters)
37
+ # Ransack drops a filter it does not recognise and returns the whole
38
+ # collection, which a model reports as the answer ("you have 38
39
+ # products with status notreal"). The Admin API will refuse those
40
+ # itself once strict filters land; until then this is the only thing
41
+ # between a typo and a confident wrong answer, so it stays.
42
+ unrecognised = unrecognised_filters(entry, applied)
43
+ return invalid_filters(entry, unrecognised) if unrecognised.any?
44
+
45
+ response = dispatch.call(
46
+ method: :get,
47
+ path: entry.api_path(:index),
48
+ params: query_for(applied, sort, limit)
49
+ )
50
+ return { error: response.error_message } unless response.success?
51
+
52
+ rows = Array(response.data)
53
+ meta = response.meta || {}
54
+
55
+ {
56
+ resource: entry.key,
57
+ # What the endpoint counted, not what this page holds — the model is
58
+ # asked how many there are, and would otherwise say "10 products"
59
+ # for a store holding hundreds.
60
+ total: meta['count'] || rows.size,
61
+ count: rows.size,
62
+ records: rows.map { |row| RecordSummary.from_payload(entry: entry, payload: row) }
63
+ }
64
+ end
65
+
66
+ def summary(arguments)
67
+ label = arguments[:resource].to_s.tr('_', ' ')
68
+ filters = arguments[:filters]
69
+ return "Search #{label}" if filters.blank?
70
+
71
+ described = filters.keys.map { |key| key.to_s.tr('_', ' ') }.join(', ')
72
+ "Search #{label} by #{described}"
73
+ end
74
+
75
+ private
76
+
77
+ def dispatch
78
+ @dispatch ||= ApiDispatch.new(context)
79
+ end
80
+
81
+ # Ransack filters travel under `q`, the way every other client sends
82
+ # them — so an unknown one is refused by the API's own filter
83
+ # validation rather than silently widening the search here.
84
+ def query_for(filters, sort, limit)
85
+ params = { limit: capped_limit(limit) }
86
+ params[:q] = filters if filters.any?
87
+ params[:sort] = sort if sort.present?
88
+ params
89
+ end
90
+
91
+ # Empty values match everything, so they are dropped — but `false` is a
92
+ # real filter value (`{"out_of_stock": false}`), which `compact_blank`
93
+ # would have thrown away.
94
+ def sanitize_filters(filters)
95
+ (filters || {}).to_h.reject { |_key, value| value.nil? || value == '' }
96
+ end
97
+
98
+ def capped_limit(limit)
99
+ return DEFAULT_LIMIT if limit.blank?
100
+
101
+ [[limit.to_i, 1].max, MAX_LIMIT].min
102
+ end
103
+
104
+ # A filter key is either a named scope (`out_of_stock`) or an
105
+ # `<attribute>_<predicate>` pair. BOTH halves are checked, because
106
+ # checking only the attribute prefix let `status_notarealpredicate`
107
+ # through.
108
+ def unrecognised_filters(entry, filters)
109
+ scopes = entry.filterable_scopes
110
+
111
+ filters.keys.reject do |key|
112
+ name = key.to_s
113
+ scopes.include?(name) || valid_attribute_predicate?(entry, name)
114
+ end
115
+ end
116
+
117
+ # Longest attribute first, so a field whose name is a prefix of another
118
+ # (`name` vs `name_of_thing`) does not shadow it.
119
+ def valid_attribute_predicate?(entry, key)
120
+ entry.filterable_fields.sort_by { |field| -field.length }.any? do |field|
121
+ next false unless key.start_with?(field)
122
+ next true if key == field
123
+
124
+ ransack_predicates.include?(key.delete_prefix("#{field}_"))
125
+ end
126
+ end
127
+
128
+ def ransack_predicates
129
+ @ransack_predicates ||= Ransack.predicates.keys.to_set
130
+ end
131
+
132
+ def invalid_filters(entry, keys)
133
+ {
134
+ error: "Unknown filter(s): #{keys.join(', ')}",
135
+ filterable_fields: entry.filterable_fields,
136
+ filterable_scopes: entry.filterable_scopes
137
+ }
138
+ end
139
+
140
+ def forbidden(entry)
141
+ { error: "You do not have permission to read #{entry.key}." }
142
+ end
143
+
144
+ # A resource reached only under a parent, or one whose controller
145
+ # resolves its scope per request. Named rather than guessed at, because
146
+ # a tool cannot invent the parent id such a path needs.
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
+ }
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,52 @@
1
+ module Spree
2
+ module AgentTools
3
+ # Sets how many of something is on a shelf, through the Admin API's own
4
+ # bulk endpoint.
5
+ #
6
+ # Named for what a merchant says — "forty in stock", "two fewer, damaged"
7
+ # — rather than for the operation. Either an absolute figure or a signed
8
+ # change, per variant and location, as many at once as the merchant has
9
+ # counted.
10
+ class SetStock < Spree::AgentTool
11
+ tool_name 'set_stock'
12
+ description 'Set how many units are on hand, or adjust by a difference. Each row names ' \
13
+ 'a variant and a stock location, then either count_on_hand (the figure it ' \
14
+ 'should end at) or adjustment (a signed change). Give a reason so the stock ' \
15
+ 'history explains it.'
16
+ permission 'write_stock'
17
+ mutating!
18
+
19
+ param :rows, type: :array, items: :object, required: true,
20
+ description: 'One per shelf: {"variant_id": "variant_x", ' \
21
+ '"stock_location_id": "sl_y", "count_on_hand": 40, ' \
22
+ '"reason": "received"}'
23
+
24
+ def call(rows:)
25
+ prepared = Array(rows).map { |row| row.respond_to?(:to_h) ? row.to_h.stringify_keys : {} }
26
+ return { error: 'Give at least one row.' } if prepared.empty?
27
+
28
+ response = ApiDispatch.new(context).call(
29
+ method: :post, path: '/api/v3/admin/stock_levels/bulk_upsert', body: { stock_levels: prepared }
30
+ )
31
+
32
+ unless response.success?
33
+ # The endpoint names the missing field per row, which is what lets a
34
+ # model repair its own request rather than resend it whole.
35
+ rows = response.body.is_a?(Hash) ? response.body.dig('error', 'details', 'rows') : nil
36
+
37
+ return { error: response.error_message, rows: rows }.compact
38
+ end
39
+
40
+ changed = response.body.is_a?(Hash) ? response.body['stock_level_count'] : nil
41
+
42
+ { ok: true, shelves_changed: changed || prepared.size }
43
+ end
44
+
45
+ def summary(arguments)
46
+ count = Array(arguments[:rows]).size
47
+
48
+ "Set stock on #{count} #{'shelf'.pluralize(count)}"
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,42 @@
1
+ module Spree
2
+ module AgentTools
3
+ # Changes a record, through the Admin API's own update operation — which
4
+ # runs whatever workflow the controller declares, so this is the same
5
+ # write the dashboard performs.
6
+ class UpdateResource < Spree::AgentTools::ResourceWrite
7
+ tool_name 'update_resource'
8
+ description 'Change a record. Call describe_resource first for the attributes a ' \
9
+ 'resource accepts, and send only the ones to change. A list-valued ' \
10
+ 'attribute is the exception: it REPLACES the whole list, so send every row ' \
11
+ 'to keep, not only the new ones — sending one variant leaves a product ' \
12
+ 'with one variant.'
13
+
14
+ param :resource, description: 'Resource key — call describe_resource for the list', required: true
15
+ param :id, description: 'The record id as shown in search results', required: true
16
+ param :attributes, type: :object, description: 'Attributes to change', required: true
17
+
18
+ def call(resource:, id:, attributes: {})
19
+ entry, refusal = writable_entry(resource, :update)
20
+ return refusal if refusal
21
+
22
+ body, rejection = body_for(entry, attributes)
23
+ return rejection if rejection
24
+
25
+ response = dispatch.call(
26
+ method: :patch,
27
+ path: entry.api_path(:update).sub(':id', id.to_s),
28
+ body: body
29
+ )
30
+
31
+ result_for(response, entry, summary: summary(resource: resource, id: id, attributes: attributes))
32
+ end
33
+
34
+ def summary(arguments)
35
+ changed = arguments[:attributes].to_h.keys.map { |key| key.to_s.tr('_', ' ') }
36
+
37
+ "Update #{arguments[:resource].to_s.singularize.tr('_', ' ')} #{arguments[:id]}" \
38
+ "#{": #{changed.to_sentence}" if changed.any?}"
39
+ end
40
+ end
41
+ end
42
+ end