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.
- checksums.yaml +4 -4
- data/app/mailers/spree/email_template_mailer.rb +5 -2
- data/app/models/concerns/spree/has_external_references.rb +7 -6
- data/app/models/concerns/spree/preference_schema/json_schema.rb +8 -1
- data/app/models/concerns/spree/purchase/taxation.rb +44 -3
- data/app/models/concerns/spree/translatable_resource.rb +49 -0
- data/app/models/spree/cart.rb +1 -0
- data/app/models/spree/delivery_rate.rb +12 -4
- data/app/models/spree/delivery_rate_provider/base.rb +1 -1
- data/app/models/spree/delivery_rate_provider/estimate.rb +5 -4
- data/app/models/spree/fulfillment.rb +1 -0
- data/app/models/spree/gateway/bogus.rb +7 -0
- data/app/models/spree/line_item.rb +3 -8
- data/app/models/spree/oauth_access_grant.rb +11 -0
- data/app/models/spree/oauth_access_token.rb +16 -0
- data/app/models/spree/oauth_application.rb +97 -0
- data/app/models/spree/oauth_consent_request.rb +23 -0
- data/app/models/spree/oauth_protected_resource.rb +21 -0
- data/app/models/spree/oauth_redirect.rb +12 -0
- data/app/models/spree/option_type.rb +5 -0
- data/app/models/spree/option_value.rb +5 -0
- data/app/models/spree/order.rb +5 -1
- data/app/models/spree/payment.rb +14 -2
- data/app/models/spree/payment_capture_event.rb +2 -0
- data/app/models/spree/policy.rb +6 -0
- data/app/models/spree/price.rb +16 -33
- data/app/models/spree/price_rule.rb +3 -16
- data/app/models/spree/price_rules/market_rule.rb +0 -6
- data/app/models/spree/pricing_provider/base.rb +1 -1
- data/app/models/spree/product.rb +1 -1
- data/app/models/spree/purchase_order_item.rb +1 -1
- data/app/models/spree/return.rb +1 -0
- data/app/models/spree/search_provider/database.rb +1 -1
- data/app/models/spree/stock/estimator.rb +4 -20
- data/app/models/spree/store.rb +8 -0
- data/app/models/spree/tax_exemption/item_override.rb +14 -0
- data/app/models/spree/tax_exemption.rb +26 -0
- data/app/models/spree/tax_exemption_certificate.rb +13 -2
- data/app/models/spree/tax_exemption_reason.rb +62 -0
- data/app/models/spree/tax_provider/base.rb +24 -4
- data/app/models/spree/tax_provider/internal.rb +36 -29
- data/app/models/spree/tax_rate.rb +9 -35
- data/app/models/spree/translations/batch.rb +14 -4
- data/app/services/spree/carts/price_items.rb +2 -8
- data/app/services/spree/carts/split_by_seller.rb +6 -1
- data/app/services/spree/carts/update.rb +34 -7
- data/app/services/spree/email_templates/preview.rb +24 -3
- data/app/services/spree/seeds/store_resources.rb +1 -0
- data/app/services/spree/seeds/tax_exemption_reasons.rb +28 -0
- data/app/services/spree/tax/resolve_exemptions.rb +11 -2
- data/app/services/spree/update_check.rb +8 -1
- data/app/tools/spree/agent_tools/create_export.rb +116 -0
- data/app/tools/spree/agent_tools/create_import.rb +117 -0
- data/app/tools/spree/agent_tools/create_media.rb +81 -0
- data/app/tools/spree/agent_tools/create_resource.rb +36 -0
- data/app/tools/spree/agent_tools/curate_products.rb +90 -0
- data/app/tools/spree/agent_tools/delete_resource.rb +28 -0
- data/app/tools/spree/agent_tools/describe_import_mapping.rb +73 -0
- data/app/tools/spree/agent_tools/describe_reporting.rb +39 -0
- data/app/tools/spree/agent_tools/describe_resource.rb +81 -0
- data/app/tools/spree/agent_tools/get_import_status.rb +71 -0
- data/app/tools/spree/agent_tools/get_resource.rb +97 -0
- data/app/tools/spree/agent_tools/import_tool.rb +80 -0
- data/app/tools/spree/agent_tools/propose_import_mapping.rb +138 -0
- data/app/tools/spree/agent_tools/query_report.rb +124 -0
- data/app/tools/spree/agent_tools/resource_write.rb +99 -0
- data/app/tools/spree/agent_tools/search_resources.rb +155 -0
- data/app/tools/spree/agent_tools/set_stock.rb +52 -0
- data/app/tools/spree/agent_tools/update_resource.rb +42 -0
- data/app/tools/spree/agent_tools/upload_file.rb +104 -0
- data/app/workflows/spree/carts/complete.rb +115 -2
- data/app/workflows/spree/catalogs/set_price_list.rb +2 -2
- data/app/workflows/spree/orders/cancel.rb +8 -0
- data/app/workflows/spree/orders/complete.rb +8 -0
- data/app/{services → workflows}/spree/orders/create.rb +9 -9
- data/app/workflows/spree/purchase_orders/create.rb +3 -0
- data/app/workflows/spree/returns/refund.rb +4 -0
- data/app/{services → workflows}/spree/stock_levels/bulk_upsert.rb +49 -7
- data/app/workflows/spree/variants/create.rb +7 -4
- data/config/locales/en.yml +14 -0
- data/db/migrate/20250902143122_fix_policies_store_association.rb +6 -4
- data/db/migrate/20260515000001_add_store_id_to_spree_newsletter_subscribers.rb +11 -2
- data/db/migrate/20260904100001_add_applied_tax_exemptions_to_spree_orders.rb +16 -0
- data/db/migrate/20260914230000_add_store_to_spree_option_types.rb +12 -2
- data/db/migrate/20261005120000_create_spree_oauth_tables.rb +67 -0
- data/db/migrate/20261016000001_remove_default_from_spree_tax_rates_included_in_price.rb +9 -0
- data/db/migrate/20261016100001_create_spree_tax_exemption_reasons.rb +21 -0
- data/db/migrate/20261017100001_add_tax_exemption_reason_to_spree_tax_exemption_certificates.rb +9 -0
- data/db/sample_data/orders.rb +1 -1
- data/lib/spree/agent_tool.rb +188 -0
- data/lib/spree/agent_tools/api_dispatch.rb +204 -0
- data/lib/spree/agent_tools/context.rb +191 -0
- data/lib/spree/agent_tools/default_catalog.rb +180 -0
- data/lib/spree/agent_tools/membership_map.rb +61 -0
- data/lib/spree/agent_tools/record_summary.rb +219 -0
- data/lib/spree/agent_tools/registry.rb +131 -0
- data/lib/spree/agent_tools/resource_map.rb +352 -0
- data/lib/spree/agent_tools/workflow_endpoints.rb +88 -0
- data/lib/spree/agent_tools/workflow_schema.rb +279 -0
- data/lib/spree/agent_tools/workflow_tool.rb +654 -0
- data/lib/spree/agent_tools.rb +24 -0
- data/lib/spree/core/configuration.rb +1 -1
- data/lib/spree/core/controller_helpers/store.rb +3 -13
- data/lib/spree/core/dependencies.rb +9 -0
- data/lib/spree/core/engine.rb +35 -1
- data/lib/spree/core/model_extensions.rb +111 -0
- data/lib/spree/core/permissions/default_catalog.rb +6 -1
- data/lib/spree/core/pricing/price_resolution.rb +4 -4
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/core.rb +27 -2
- data/lib/spree/service_module.rb +11 -0
- data/lib/spree/testing_support/factories/tax_exemption_certificate_factory.rb +13 -1
- data/lib/spree/testing_support/factories/tax_exemption_reason_factory.rb +8 -0
- data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +38 -0
- data/lib/tasks/markets.rake +47 -0
- data/lib/tasks/tax_exemption_reasons.rake +48 -0
- metadata +67 -7
- data/app/models/concerns/spree/vat_price_calculation.rb +0 -99
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module AgentTools
|
|
3
|
+
# One agent tool per exposed workflow.
|
|
4
|
+
#
|
|
5
|
+
# Every back-office write already goes through a workflow, so there is
|
|
6
|
+
# nothing to hand-write: the allowlist names a `Spree::Dependencies` key
|
|
7
|
+
# and a permission, {WorkflowSchema} reads the argument contract off the
|
|
8
|
+
# workflow's own `perform`, and this class resolves the arguments, injects
|
|
9
|
+
# the principal, takes the lock the controller takes and shapes the result.
|
|
10
|
+
#
|
|
11
|
+
# Generated classes are anonymous subclasses cached per dependency key, so
|
|
12
|
+
# a host app that swaps `Spree.order_cancel_workflow` for its own class
|
|
13
|
+
# keeps the tool and gets the replacement's schema.
|
|
14
|
+
class WorkflowTool < Spree::AgentTool
|
|
15
|
+
class_attribute :dependency_key, instance_accessor: false
|
|
16
|
+
class_attribute :schema_except, instance_accessor: false, default: [].freeze
|
|
17
|
+
class_attribute :summary_override, instance_accessor: false
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
# Builds (and caches) the tool class for one exposed workflow.
|
|
21
|
+
#
|
|
22
|
+
# @param dependency_key [Symbol] a `Spree::Dependencies` key
|
|
23
|
+
# @param permission [String] the `write_*` key gating it
|
|
24
|
+
# @param except [Array<Symbol>] parameters to hide
|
|
25
|
+
# @param summary [#call, nil] overrides the generated confirmation line
|
|
26
|
+
# @return [Class<WorkflowTool>, nil] nil when the key does not resolve
|
|
27
|
+
def for(dependency_key, permission:, except: [], summary: nil)
|
|
28
|
+
workflow_class = resolve_workflow(dependency_key)
|
|
29
|
+
return if workflow_class.nil?
|
|
30
|
+
|
|
31
|
+
# Keyed by what actually shapes the class, including the workflow's
|
|
32
|
+
# own source location and mtime: a tool's parameters are read off
|
|
33
|
+
# `perform`, so editing a workflow in development has to produce a
|
|
34
|
+
# new tool rather than serve the schema built before the reload.
|
|
35
|
+
# That is the same reason the registry stores names and not classes.
|
|
36
|
+
#
|
|
37
|
+
# A summary override is a callable with no stable identity, so its
|
|
38
|
+
# presence is part of the key but the object is not — otherwise
|
|
39
|
+
# every reload would leave a permanent entry behind.
|
|
40
|
+
cache_key = [dependency_key, workflow_class.name, source_fingerprint(workflow_class),
|
|
41
|
+
permission, except, !summary.nil?]
|
|
42
|
+
cached = cache[cache_key]
|
|
43
|
+
return cached if cached && cached.summary_override.equal?(summary)
|
|
44
|
+
|
|
45
|
+
cache[cache_key] = build(dependency_key, workflow_class, permission, except, summary)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @return [void]
|
|
49
|
+
def clear_cache!
|
|
50
|
+
@cache = {}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Identifies the source a tool's schema was read from, so an edit in
|
|
54
|
+
# development invalidates the generated class. Nil in a packaged gem
|
|
55
|
+
# whose source is not on disk, which simply means the key never
|
|
56
|
+
# changes there — correct, since the source cannot change either.
|
|
57
|
+
#
|
|
58
|
+
# @return [Array, nil]
|
|
59
|
+
def source_fingerprint(workflow_class)
|
|
60
|
+
file, = workflow_class.instance_method(:perform).source_location
|
|
61
|
+
return if file.nil?
|
|
62
|
+
|
|
63
|
+
[file, File.mtime(file)]
|
|
64
|
+
rescue StandardError
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The tool name for a dependency key: the workflow's dotted identity
|
|
69
|
+
# with dots as underscores, because MCP names allow only letters,
|
|
70
|
+
# digits, underscore and hyphen.
|
|
71
|
+
#
|
|
72
|
+
# @param workflow_class [Class]
|
|
73
|
+
# @return [String]
|
|
74
|
+
def tool_name_for(workflow_class)
|
|
75
|
+
workflow_class.workflow_key.tr('.', '_')
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @return [Class, nil]
|
|
79
|
+
def resolve_workflow(dependency_key)
|
|
80
|
+
Spree.public_send(dependency_key)
|
|
81
|
+
rescue NoMethodError, NameError
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The workflow's own leading comment, first paragraph — what the class
|
|
86
|
+
# says it does, in the author's words rather than a second description
|
|
87
|
+
# kept in step by hand.
|
|
88
|
+
#
|
|
89
|
+
# @param workflow_class [Class]
|
|
90
|
+
# @return [String]
|
|
91
|
+
def description_for(workflow_class)
|
|
92
|
+
paragraph = leading_comment(workflow_class)
|
|
93
|
+
return "Runs the #{workflow_class.workflow_key.tr('.', ' ')} workflow." if paragraph.blank?
|
|
94
|
+
|
|
95
|
+
paragraph
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def cache
|
|
101
|
+
@cache ||= {}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def build(dependency_key, workflow_class, permission_key, except, summary)
|
|
105
|
+
derived_schema = WorkflowSchema.new(workflow_class, except: except)
|
|
106
|
+
derived_name = tool_name_for(workflow_class)
|
|
107
|
+
derived_description = description_for(workflow_class)
|
|
108
|
+
|
|
109
|
+
Class.new(self) do
|
|
110
|
+
self.dependency_key = dependency_key
|
|
111
|
+
self.schema_except = except.freeze
|
|
112
|
+
self.summary_override = summary
|
|
113
|
+
|
|
114
|
+
tool_name derived_name
|
|
115
|
+
description derived_description
|
|
116
|
+
permission permission_key
|
|
117
|
+
mutating!
|
|
118
|
+
|
|
119
|
+
derived_schema.parameters.each do |parameter|
|
|
120
|
+
param parameter[:name],
|
|
121
|
+
type: parameter[:json_type].to_sym,
|
|
122
|
+
items: parameter[:item_type]&.to_sym,
|
|
123
|
+
description: parameter[:description],
|
|
124
|
+
required: parameter[:required]
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def leading_comment(workflow_class)
|
|
130
|
+
location = Object.const_source_location(workflow_class.name)
|
|
131
|
+
return if location.nil?
|
|
132
|
+
|
|
133
|
+
file, line = location
|
|
134
|
+
return unless file && File.exist?(file)
|
|
135
|
+
|
|
136
|
+
lines = File.readlines(file)
|
|
137
|
+
comment = []
|
|
138
|
+
index = line - 2
|
|
139
|
+
while index >= 0 && lines[index].strip.start_with?('#')
|
|
140
|
+
comment.unshift(lines[index].strip.delete_prefix('#').strip)
|
|
141
|
+
index -= 1
|
|
142
|
+
end
|
|
143
|
+
# First paragraph only — the rest is design rationale for readers of
|
|
144
|
+
# the class, not for a model choosing between tools.
|
|
145
|
+
comment.take_while(&:present?).join(' ').presence
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# @return [Class] the workflow this tool runs, resolved now so a host
|
|
150
|
+
# app's replacement is picked up without a restart
|
|
151
|
+
def workflow_class
|
|
152
|
+
self.class.resolve_workflow(self.class.dependency_key)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# @return [WorkflowSchema]
|
|
156
|
+
def schema
|
|
157
|
+
@schema ||= WorkflowSchema.new(workflow_class, except: self.class.schema_except)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Runs the workflow with the caller's arguments.
|
|
161
|
+
#
|
|
162
|
+
# Model-typed arguments arrive as prefixed ids and are resolved through
|
|
163
|
+
# the resource map under the context's store, so another store's id is
|
|
164
|
+
# simply not found. Principal parameters are never accepted from the
|
|
165
|
+
# caller — they are filled from the authenticated principal.
|
|
166
|
+
#
|
|
167
|
+
# @return [Hash] the result handed back to the model
|
|
168
|
+
def call(**arguments)
|
|
169
|
+
resolved = resolve_arguments(arguments)
|
|
170
|
+
return resolved if resolved.is_a?(Hash) && resolved[:error]
|
|
171
|
+
|
|
172
|
+
# The endpoint runs this workflow behind its own routing,
|
|
173
|
+
# authentication, authorization and serializer, so when there is one
|
|
174
|
+
# the request goes through it rather than beside it.
|
|
175
|
+
return dispatch(resolved) if endpoint
|
|
176
|
+
|
|
177
|
+
refusal = unauthorized_create || unauthorized_record(resolved)
|
|
178
|
+
return refusal if refusal
|
|
179
|
+
|
|
180
|
+
keywords = resolved.merge(principal_arguments).merge(context_arguments)
|
|
181
|
+
|
|
182
|
+
run(keywords, subject: subject_record(resolved))
|
|
183
|
+
rescue ArgumentError => e
|
|
184
|
+
{ error: e.message }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# @return [Spree::AgentTools::WorkflowEndpoints::Entry, nil]
|
|
188
|
+
def endpoint
|
|
189
|
+
return @endpoint if defined?(@endpoint)
|
|
190
|
+
|
|
191
|
+
@endpoint = WorkflowEndpoints.find(self.class.dependency_key)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# One line saying what this tool did, for the client's confirmation
|
|
195
|
+
# prompt and the assistant's approval card.
|
|
196
|
+
#
|
|
197
|
+
# @param arguments [Hash]
|
|
198
|
+
# @return [String]
|
|
199
|
+
def summary(arguments, subject: nil)
|
|
200
|
+
return self.class.summary_override.call(context, arguments) if self.class.summary_override
|
|
201
|
+
|
|
202
|
+
action = workflow_class.workflow_key.tr('.', ' ').humanize
|
|
203
|
+
subject ||= subject_record(arguments)
|
|
204
|
+
[action, label_for(subject)].compact.join(' ')
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
private
|
|
208
|
+
|
|
209
|
+
# Runs the workflow by calling the operation that runs it.
|
|
210
|
+
#
|
|
211
|
+
# The path's `:id` is the record the write is about; a parent segment
|
|
212
|
+
# (`:order_id` on a payment) is read from that record rather than asked
|
|
213
|
+
# for, since the caller named the payment and the order follows from it.
|
|
214
|
+
# Everything else travels in the body under the name the endpoint
|
|
215
|
+
# documents, which is not always the workflow's keyword — a
|
|
216
|
+
# cancellation's `note:` is `cancel_note` on the wire.
|
|
217
|
+
def dispatch(resolved)
|
|
218
|
+
subject = subject_record(resolved)
|
|
219
|
+
path, missing = endpoint_path(subject, resolved)
|
|
220
|
+
return { error: missing } if missing
|
|
221
|
+
|
|
222
|
+
response = ApiDispatch.new(context).call(
|
|
223
|
+
method: endpoint.verb.downcase.to_sym, path: path, body: dispatch_body(resolved)
|
|
224
|
+
)
|
|
225
|
+
return { error: response.error_message } unless response.success?
|
|
226
|
+
|
|
227
|
+
{ summary: summary(resolved, subject: subject),
|
|
228
|
+
record: payload_summary(response.data) }.compact
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def endpoint_path(subject, resolved)
|
|
232
|
+
values = {}
|
|
233
|
+
|
|
234
|
+
endpoint.path_params.each do |segment|
|
|
235
|
+
record = segment == 'id' ? subject : parent_for(segment, subject, resolved)
|
|
236
|
+
return [nil, "This tool needs a #{segment.sub(/_id\z/, '').humanize.downcase} it was not given."] if
|
|
237
|
+
record.nil?
|
|
238
|
+
|
|
239
|
+
values[segment] = record.prefixed_id
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
[endpoint.path_for(values), nil]
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# A parent segment names an association: `:order_id` on a payment is
|
|
246
|
+
# `payment.order`. A record the caller named for that association is
|
|
247
|
+
# preferred, so a split payment is refunded against the order the
|
|
248
|
+
# caller meant.
|
|
249
|
+
def parent_for(segment, subject, resolved)
|
|
250
|
+
name = segment.sub(/_id\z/, '')
|
|
251
|
+
named = resolved[name.to_sym]
|
|
252
|
+
return named if named.is_a?(ActiveRecord::Base)
|
|
253
|
+
|
|
254
|
+
from_subject = subject.try(name)
|
|
255
|
+
return from_subject if from_subject
|
|
256
|
+
|
|
257
|
+
# A keyword whose name does not match the segment but whose record is
|
|
258
|
+
# what the segment addresses: a label's `owner:` is the fulfillment in
|
|
259
|
+
# `/fulfillments/:fulfillment_id`, and the same tool reaches a return
|
|
260
|
+
# through `/returns/:return_id`.
|
|
261
|
+
records(resolved).find { |record| record.class.name.demodulize.underscore == name }
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# What the operation accepts, under its own names. A record resolved for
|
|
265
|
+
# a path segment is already in the path; anything else the endpoint does
|
|
266
|
+
# not document is left out rather than sent and ignored.
|
|
267
|
+
def dispatch_body(resolved)
|
|
268
|
+
accepted = endpoint.body_keys
|
|
269
|
+
return {} if accepted.empty?
|
|
270
|
+
|
|
271
|
+
resolved.each_with_object({}) do |(name, value), body|
|
|
272
|
+
# An `attributes` hash is not a field of the body — it IS the body.
|
|
273
|
+
# The endpoint documents its keys one by one, so it merges in
|
|
274
|
+
# rather than travelling under a name of its own.
|
|
275
|
+
if name.to_s == 'attributes' && value.is_a?(Hash)
|
|
276
|
+
body.merge!(value.transform_keys(&:to_s))
|
|
277
|
+
next
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
key = body_key_for(name, accepted)
|
|
281
|
+
next if key.nil?
|
|
282
|
+
|
|
283
|
+
body[key] = value.is_a?(ActiveRecord::Base) ? value.prefixed_id : value
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# The endpoint's name for a workflow keyword.
|
|
288
|
+
#
|
|
289
|
+
# Exact first, then the `_id` form a record takes on the wire. Failing
|
|
290
|
+
# both, the one accepted key that ENDS in the keyword: a cancellation's
|
|
291
|
+
# `reason:` is `cancel_reason_id` and its `note:` is `cancel_note`,
|
|
292
|
+
# qualified by the action rather than the resource. Matched on the
|
|
293
|
+
# suffix rather than by guessing the qualifier, and only when exactly
|
|
294
|
+
# one key matches — two would make the choice a coin toss, and a
|
|
295
|
+
# silently misplaced value is worse than a refusal.
|
|
296
|
+
def body_key_for(name, accepted)
|
|
297
|
+
exact = [name.to_s, "#{name}_id"].find { |candidate| accepted.include?(candidate) }
|
|
298
|
+
return exact if exact
|
|
299
|
+
|
|
300
|
+
suffixed = accepted.select { |key| key.end_with?("_#{name}", "_#{name}_id") }
|
|
301
|
+
suffixed.first if suffixed.one?
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# A record as the endpoint returned it, summarised the way a record read
|
|
305
|
+
# from the map would be.
|
|
306
|
+
def payload_summary(data)
|
|
307
|
+
return unless data.is_a?(Hash)
|
|
308
|
+
|
|
309
|
+
entry = ResourceMap.all.find { |candidate| candidate.key == data['object'] } ||
|
|
310
|
+
subject_entry
|
|
311
|
+
return { id: data['id'] } if entry.nil?
|
|
312
|
+
|
|
313
|
+
RecordSummary.from_payload(entry: entry, payload: data)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Holding `write_orders` says an admin may change orders; it does not say
|
|
317
|
+
# they may change *this* one. A host app narrows that with a
|
|
318
|
+
# record-level rule, the Admin API honours it, and a workflow tool must
|
|
319
|
+
# too — otherwise an agent writes records its user could not touch by
|
|
320
|
+
# clicking. Every record the workflow was handed is checked, not just the
|
|
321
|
+
# subject, since a cancellation reason from another seller is as much a
|
|
322
|
+
# leak as the order itself.
|
|
323
|
+
def unauthorized_record(arguments)
|
|
324
|
+
# A create's own action is checked against the class above; the records
|
|
325
|
+
# it was handed are collaborators (a supplier, a stock location), so
|
|
326
|
+
# they are checked for the read-and-use they actually get.
|
|
327
|
+
action = ability_action == :create ? :show : ability_action
|
|
328
|
+
|
|
329
|
+
records(arguments).each do |record|
|
|
330
|
+
refusal = unauthorized(action, record)
|
|
331
|
+
return refusal if refusal
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
nil
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# A create workflow has no record to check yet — the workflow builds it
|
|
338
|
+
# — so the Admin API's `authorize_resource!(@resource, :create)` has no
|
|
339
|
+
# counterpart here. Authorize against the class instead, which is what a
|
|
340
|
+
# record-level rule on a create would be evaluated against anyway.
|
|
341
|
+
#
|
|
342
|
+
# @return [Hash, nil]
|
|
343
|
+
def unauthorized_create
|
|
344
|
+
return unless ability_action == :create
|
|
345
|
+
|
|
346
|
+
# A key whose model does not resolve leaves nothing to authorize
|
|
347
|
+
# against, and `can?(:create, nil)` answers true — so the check would
|
|
348
|
+
# pass for exactly the workflow nobody can vouch for. The attribute
|
|
349
|
+
# filter is keyed off the same lookup and would also fall open, which
|
|
350
|
+
# is the tenancy hole it exists to close.
|
|
351
|
+
model = created_model
|
|
352
|
+
return { error: 'This tool cannot be used: its workflow names a resource that does not exist.' } if model.nil?
|
|
353
|
+
return if context.can?(:create, model)
|
|
354
|
+
|
|
355
|
+
{ error: "You do not have permission to create this #{model.model_name.human.downcase}." }
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# What the workflow creates, named by its own key: `products.create`
|
|
359
|
+
# creates a product. A key whose model cannot be resolved falls back to
|
|
360
|
+
# the subject check alone rather than guessing.
|
|
361
|
+
#
|
|
362
|
+
# @return [Class, nil]
|
|
363
|
+
def created_model
|
|
364
|
+
@created_model ||= "Spree::#{workflow_class.workflow_key.split('.').first.classify}".safe_constantize
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Which CanCanCan action this workflow amounts to. The stock catalog
|
|
368
|
+
# grants writes as `:manage`, which covers everything — but an ability
|
|
369
|
+
# may be replaced, and a host app that grants `:update` without
|
|
370
|
+
# `:destroy` means it: a deletion checked as an update would go through.
|
|
371
|
+
#
|
|
372
|
+
# @return [Symbol]
|
|
373
|
+
def ability_action
|
|
374
|
+
case workflow_class.workflow_key.split('.').last
|
|
375
|
+
when 'destroy' then :destroy
|
|
376
|
+
when 'create' then :create
|
|
377
|
+
else :update
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Only what the caller named. The store and the principal are injected
|
|
382
|
+
# from the credential, so checking them would ask whether the caller may
|
|
383
|
+
# "update" their own store or their own user — which an order-desk role
|
|
384
|
+
# legitimately may not, and which has nothing to do with the write they
|
|
385
|
+
# actually asked for.
|
|
386
|
+
def records(arguments)
|
|
387
|
+
arguments.values.select { |value| value.is_a?(ActiveRecord::Base) }
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# The record the write is about, for the lock and the confirmation line.
|
|
391
|
+
def subject_record(arguments)
|
|
392
|
+
records(arguments).first
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def run(keywords, subject: nil)
|
|
396
|
+
result = with_subject_lock(subject) { workflow_class.call(**keywords) }
|
|
397
|
+
|
|
398
|
+
if result.success?
|
|
399
|
+
{ summary: summary(keywords, subject: result.value.is_a?(ActiveRecord::Base) ? result.value : subject),
|
|
400
|
+
record: record_result(result.value) }
|
|
401
|
+
else
|
|
402
|
+
{ error: error_text(result) }
|
|
403
|
+
end
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# The same row lock the admin controllers take for an order-subject
|
|
407
|
+
# workflow. Taken generically: whichever record the workflow is acting
|
|
408
|
+
# on is the one that must not change underneath it.
|
|
409
|
+
def with_subject_lock(subject, &)
|
|
410
|
+
return yield unless subject.respond_to?(:with_lock) && subject.persisted?
|
|
411
|
+
|
|
412
|
+
subject.with_lock(&)
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def record_result(value)
|
|
416
|
+
return unless value.is_a?(ActiveRecord::Base)
|
|
417
|
+
|
|
418
|
+
entry = ResourceMap.all.find { |candidate| value.is_a?(candidate.model_class) }
|
|
419
|
+
return { id: value.try(:prefixed_id) } if entry.nil?
|
|
420
|
+
|
|
421
|
+
RecordSummary.call(entry: entry, record: value)
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# The workflow's own refusal, in its own words, so the model can correct
|
|
425
|
+
# itself instead of retrying blind.
|
|
426
|
+
def error_text(result)
|
|
427
|
+
error = result.error
|
|
428
|
+
errors = error.respond_to?(:value) ? error.value : error
|
|
429
|
+
|
|
430
|
+
return errors.full_messages.to_sentence if errors.respond_to?(:full_messages) && errors.any?
|
|
431
|
+
return error.to_s if error.present?
|
|
432
|
+
|
|
433
|
+
'The workflow refused the change.'
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Who acted, for the workflow keywords that record it.
|
|
437
|
+
#
|
|
438
|
+
# Only where the record can actually hold this principal. Several
|
|
439
|
+
# models still declare `created_by` / `reviewed_by` as a plain
|
|
440
|
+
# `belongs_to` on the admin-user class rather than through `acted_by`,
|
|
441
|
+
# and handing those an API key raises AssociationTypeMismatch deep
|
|
442
|
+
# inside the workflow — a protocol-level failure where the honest
|
|
443
|
+
# outcome is simply an unattributed record.
|
|
444
|
+
def principal_arguments
|
|
445
|
+
principal = context.principal
|
|
446
|
+
schema.principal_parameters.
|
|
447
|
+
select { |name| principal_storable?(name, principal) }.
|
|
448
|
+
index_with { principal }
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# @return [Boolean]
|
|
452
|
+
def principal_storable?(name, principal)
|
|
453
|
+
association = subject_model&.reflect_on_association(name)
|
|
454
|
+
return true if association.nil? || association.polymorphic?
|
|
455
|
+
|
|
456
|
+
expected = association.klass
|
|
457
|
+
principal.is_a?(expected)
|
|
458
|
+
rescue NameError
|
|
459
|
+
true
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# The model this workflow writes, for checking what its associations
|
|
463
|
+
# will accept. Nil when the key names nothing resolvable.
|
|
464
|
+
def subject_model
|
|
465
|
+
return @subject_model if defined?(@subject_model)
|
|
466
|
+
|
|
467
|
+
@subject_model = created_model
|
|
468
|
+
rescue StandardError
|
|
469
|
+
@subject_model = nil
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# Tenancy comes from the credential, never from a parameter.
|
|
473
|
+
def context_arguments
|
|
474
|
+
schema.context_parameters.index_with { context.store }
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
# Turns the caller's JSON arguments into what `perform` expects,
|
|
478
|
+
# resolving every model-typed parameter from its prefixed id.
|
|
479
|
+
#
|
|
480
|
+
# @return [Hash, Hash{Symbol => String}] keywords, or an `{error:}` result
|
|
481
|
+
def resolve_arguments(arguments)
|
|
482
|
+
resolved = {}
|
|
483
|
+
|
|
484
|
+
schema.parameters.each do |parameter|
|
|
485
|
+
name = parameter[:name]
|
|
486
|
+
next unless arguments.key?(name)
|
|
487
|
+
|
|
488
|
+
value = arguments[name]
|
|
489
|
+
next if value.nil?
|
|
490
|
+
|
|
491
|
+
if parameter[:model_name]
|
|
492
|
+
record = find_record(parameter[:model_name], value)
|
|
493
|
+
return { error: "No #{parameter[:model_name].demodulize.underscore.humanize.downcase} found for #{name} #{value.inspect}." } if record.nil?
|
|
494
|
+
|
|
495
|
+
resolved[name] = record
|
|
496
|
+
elsif parameter[:item_type] == 'object'
|
|
497
|
+
rows, refusal = resolve_rows(name, value)
|
|
498
|
+
return refusal if refusal
|
|
499
|
+
|
|
500
|
+
resolved[name] = rows
|
|
501
|
+
elsif parameter[:json_type] == 'object'
|
|
502
|
+
filtered, refusal = permitted_attributes(value)
|
|
503
|
+
return refusal if refusal
|
|
504
|
+
|
|
505
|
+
resolved[name] = filtered
|
|
506
|
+
else
|
|
507
|
+
resolved[name] = value
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
resolved
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
# Only the attributes the Admin API itself would accept for the record
|
|
515
|
+
# this workflow writes.
|
|
516
|
+
#
|
|
517
|
+
# A workflow is an internal service that has always trusted its caller,
|
|
518
|
+
# so it assigns the hash it is handed. The REST controllers filter first
|
|
519
|
+
# — `permitted_params` is a fixed list that never includes a tenancy
|
|
520
|
+
# key — and a tool must do the same before a workflow sees the payload.
|
|
521
|
+
# Without it a caller naming `store_id` writes into another merchant's
|
|
522
|
+
# store, since the workflow assigns the hash after setting tenancy.
|
|
523
|
+
#
|
|
524
|
+
# Unknown keys are named back rather than dropped, matching
|
|
525
|
+
# `ResourceWrite` so a model that guessed a field learns the real one.
|
|
526
|
+
#
|
|
527
|
+
# @return [Array(Hash, nil), Array(nil, Hash)]
|
|
528
|
+
def permitted_attributes(attributes)
|
|
529
|
+
return [attributes, nil] unless attributes.is_a?(Hash)
|
|
530
|
+
|
|
531
|
+
entry = subject_entry
|
|
532
|
+
if entry.nil?
|
|
533
|
+
# No entry means no list of what the Admin API would accept, and
|
|
534
|
+
# forwarding the hash unchecked is what let a caller name
|
|
535
|
+
# `store_id` and write into another merchant's store.
|
|
536
|
+
return [nil, { error: 'This tool cannot be used: its resource is not one an agent may write.' }]
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
attributes = attributes.transform_keys(&:to_s)
|
|
540
|
+
allowed = entry.writable_attribute_names
|
|
541
|
+
return [attributes, nil] if allowed.blank?
|
|
542
|
+
|
|
543
|
+
rejected = attributes.keys - allowed
|
|
544
|
+
return [attributes, nil] if rejected.empty?
|
|
545
|
+
|
|
546
|
+
[nil, { error: "#{entry.key} does not accept #{rejected.to_sentence}. " \
|
|
547
|
+
"Accepted attributes: #{allowed.to_sentence}." }]
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
# The map entry for what this workflow writes, which owns the list of
|
|
551
|
+
# attributes the Admin API accepts for it.
|
|
552
|
+
#
|
|
553
|
+
# @return [ResourceMap::Entry, nil]
|
|
554
|
+
def subject_entry
|
|
555
|
+
return @subject_entry if defined?(@subject_entry)
|
|
556
|
+
|
|
557
|
+
model = created_model || subject_model
|
|
558
|
+
@subject_entry = model && ResourceMap.all.find { |candidate| candidate.model_name == model.name }
|
|
559
|
+
rescue StandardError
|
|
560
|
+
@subject_entry = nil
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# A list of hashes — a purchase order's or stock transfer's `items:` —
|
|
564
|
+
# names records by prefixed id inside each row, and the workflow builds
|
|
565
|
+
# associations from them. Without resolving those the association is
|
|
566
|
+
# handed a string and the record fails validation with "must exist",
|
|
567
|
+
# which reads to the model as a bad id rather than a missing step.
|
|
568
|
+
#
|
|
569
|
+
# @return [Array(Array<Hash>, nil), Array(nil, Hash)]
|
|
570
|
+
def resolve_rows(name, value)
|
|
571
|
+
rows = Array(value).map do |row|
|
|
572
|
+
next row unless row.is_a?(Hash)
|
|
573
|
+
|
|
574
|
+
resolved_row = {}
|
|
575
|
+
row.each do |field, field_value|
|
|
576
|
+
model_name = nested_model_name(field)
|
|
577
|
+
|
|
578
|
+
if model_name && field_value.is_a?(String)
|
|
579
|
+
record = find_record(model_name, field_value)
|
|
580
|
+
if record.nil?
|
|
581
|
+
return [nil, { error: "No #{field} found for #{field_value.inspect} in #{name}." }]
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
resolved_row[field.to_sym] = record
|
|
585
|
+
else
|
|
586
|
+
resolved_row[field.to_sym] = field_value
|
|
587
|
+
end
|
|
588
|
+
end
|
|
589
|
+
resolved_row
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
[rows, nil]
|
|
593
|
+
end
|
|
594
|
+
|
|
595
|
+
# Which model a row's field names, taken from the resource map so only
|
|
596
|
+
# something the caller may already read can be referenced.
|
|
597
|
+
#
|
|
598
|
+
# @return [String, nil]
|
|
599
|
+
def nested_model_name(field)
|
|
600
|
+
entry = Spree::AgentTools::ResourceMap.find(field.to_s.pluralize)
|
|
601
|
+
return unless entry
|
|
602
|
+
return unless context.permitted?(entry.permission)
|
|
603
|
+
|
|
604
|
+
entry.model_class.name
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
# Resolves a prefixed id within the caller's store, through the resource
|
|
608
|
+
# map — so tenancy is answered the same way every read is.
|
|
609
|
+
#
|
|
610
|
+
# A model the map does not hold is one with no store scoping of its own
|
|
611
|
+
# (a certificate belongs to a company, a submission to a seller). Those
|
|
612
|
+
# resolve through the mapped parent, so the id still has to belong to
|
|
613
|
+
# this store; a model with neither is not resolvable and the caller is
|
|
614
|
+
# told so rather than being handed another store's record.
|
|
615
|
+
def find_record(model_name, value)
|
|
616
|
+
relation = store_scoped_relation(model_name)
|
|
617
|
+
return if relation.nil?
|
|
618
|
+
|
|
619
|
+
relation.find_by_prefix_id(value)
|
|
620
|
+
rescue StandardError
|
|
621
|
+
nil
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
def store_scoped_relation(model_name)
|
|
625
|
+
entry = ResourceMap.all.find { |candidate| candidate.model_name == model_name }
|
|
626
|
+
return entry.scope_for(context) if entry
|
|
627
|
+
|
|
628
|
+
parent_scoped_relation(model_name.constantize)
|
|
629
|
+
end
|
|
630
|
+
|
|
631
|
+
# Narrows an unscoped model through whichever of its parents the map
|
|
632
|
+
# knows, so `certificate` is looked up among this store's companies'
|
|
633
|
+
# certificates rather than every store's.
|
|
634
|
+
def parent_scoped_relation(model_class)
|
|
635
|
+
model_class.reflect_on_all_associations(:belongs_to).each do |association|
|
|
636
|
+
next if association.polymorphic?
|
|
637
|
+
|
|
638
|
+
entry = ResourceMap.all.find { |candidate| candidate.model_name == association.klass.name }
|
|
639
|
+
next if entry.nil?
|
|
640
|
+
|
|
641
|
+
return model_class.where(association.foreign_key => entry.scope_for(context).select(:id))
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
nil
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
def label_for(record)
|
|
648
|
+
return if record.nil?
|
|
649
|
+
|
|
650
|
+
record.try(:number) || record.try(:name) || record.try(:prefixed_id)
|
|
651
|
+
end
|
|
652
|
+
end
|
|
653
|
+
end
|
|
654
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
require 'spree/agent_tools/registry'
|
|
2
|
+
require 'spree/agent_tools/context'
|
|
3
|
+
require 'spree/agent_tools/resource_map'
|
|
4
|
+
require 'spree/agent_tools/api_dispatch'
|
|
5
|
+
require 'spree/agent_tools/membership_map'
|
|
6
|
+
require 'spree/agent_tools/workflow_endpoints'
|
|
7
|
+
require 'spree/agent_tools/record_summary'
|
|
8
|
+
require 'spree/agent_tools/workflow_schema'
|
|
9
|
+
require 'spree/agent_tools/workflow_tool'
|
|
10
|
+
require 'spree/agent_tools/default_catalog'
|
|
11
|
+
|
|
12
|
+
module Spree
|
|
13
|
+
# The agent-tool contract: what an MCP client or the dashboard assistant may
|
|
14
|
+
# do on a merchant's behalf, and under whose authority.
|
|
15
|
+
#
|
|
16
|
+
# Nothing here talks to a model or a protocol. A tool is a name, a
|
|
17
|
+
# description, a JSON parameter schema, a permission key, a mutating flag
|
|
18
|
+
# and an executor — see {Spree::AgentTool} — and the adapters in `spree_mcp`
|
|
19
|
+
# and the dashboard assistant turn that into their own vocabulary.
|
|
20
|
+
#
|
|
21
|
+
# See docs/plans/6.0-mcp-server.md.
|
|
22
|
+
module AgentTools
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -157,7 +157,7 @@ module Spree
|
|
|
157
157
|
preference :track_inventory_levels, :boolean, default: true, deprecated: 'Set it on the store instead' # Determines whether to track on_hand values for variants / products.
|
|
158
158
|
preference :track_price_history, :boolean, default: true, deprecated: 'Set it on the store instead' # Records price changes for Omnibus Directive compliance. Disable for non-EU stores.
|
|
159
159
|
preference :price_history_retention_days, :integer, default: 30, deprecated: 'Set it on the store instead' # Days to retain price history records. Used by spree:price_history:prune rake task.
|
|
160
|
-
# SPREE_UPDATE_CHECK_DISABLED turns it off as well.
|
|
160
|
+
# SPREE_UPDATE_CHECK_DISABLED turns it off as well; it never runs in the test environment or on CI.
|
|
161
161
|
preference :update_check_enabled, :boolean, default: true
|
|
162
162
|
preference :use_user_locale, :boolean, default: true, deprecated: true
|
|
163
163
|
|