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,131 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module AgentTools
|
|
3
|
+
# The agent-tool registry. One per surface, the way the authentication
|
|
4
|
+
# strategies are split — {Spree.admin_agent_tools} for the back office,
|
|
5
|
+
# {Spree.store_agent_tools} for the storefront. Extensions append their
|
|
6
|
+
# own tool classes the same way they register tax or delivery-rate
|
|
7
|
+
# providers:
|
|
8
|
+
#
|
|
9
|
+
# Spree.admin_agent_tools << MyApp::AgentTools::SyncSupplier
|
|
10
|
+
#
|
|
11
|
+
# Registration is by class, so one registry serves every adapter on its
|
|
12
|
+
# surface: the MCP server and the dashboard assistant both read the admin
|
|
13
|
+
# one.
|
|
14
|
+
#
|
|
15
|
+
# A tool also declares the surface it belongs to, so filing one in the
|
|
16
|
+
# wrong registry is refused rather than discovered when a storefront
|
|
17
|
+
# visitor is offered the back office. Ten admin tools carry no permission
|
|
18
|
+
# of their own — they are gated per resource instead — so the registry
|
|
19
|
+
# cannot be the only thing keeping them apart.
|
|
20
|
+
#
|
|
21
|
+
# Writes are not registered as classes. A back-office write already is a
|
|
22
|
+
# workflow, so the registry exposes workflows by their `Spree::Dependencies`
|
|
23
|
+
# key and derives each tool from the workflow's own `perform` signature —
|
|
24
|
+
# see {#expose_workflows}.
|
|
25
|
+
class Registry
|
|
26
|
+
include Enumerable
|
|
27
|
+
|
|
28
|
+
# @return [Symbol] the surface this registry serves
|
|
29
|
+
attr_reader :audience
|
|
30
|
+
|
|
31
|
+
def initialize(tool_names = [], audience: :admin)
|
|
32
|
+
@tool_names = tool_names.map(&:to_s)
|
|
33
|
+
@exposed_workflows = {}
|
|
34
|
+
@audience = audience.to_sym
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Registered by name, resolved on use.
|
|
38
|
+
#
|
|
39
|
+
# Holding the class itself would pin the copy loaded at boot, so in
|
|
40
|
+
# development every edit to a tool's description, params or permission
|
|
41
|
+
# needed a restart to take effect — and tuning those descriptions is how
|
|
42
|
+
# an agent's behaviour is shaped. Names survive reloading, which is the
|
|
43
|
+
# same reason `Spree.integrations` stores strings.
|
|
44
|
+
#
|
|
45
|
+
# @param tool_class [Class<Spree::AgentTool>, String]
|
|
46
|
+
# @return [self]
|
|
47
|
+
def <<(tool_class)
|
|
48
|
+
name = tool_class.to_s
|
|
49
|
+
declared = tool_class.try(:audience)
|
|
50
|
+
if declared && declared != audience
|
|
51
|
+
raise ArgumentError,
|
|
52
|
+
"#{name} belongs to the #{declared} surface and cannot be registered on the #{audience} one"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
@tool_names << name unless @tool_names.include?(name)
|
|
56
|
+
self
|
|
57
|
+
end
|
|
58
|
+
alias add <<
|
|
59
|
+
|
|
60
|
+
def each(&)
|
|
61
|
+
to_a.each(&)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Exposes workflows as write tools, keyed by `Spree::Dependencies` key so
|
|
65
|
+
# the tool follows whatever class the host app has registered:
|
|
66
|
+
#
|
|
67
|
+
# Spree.admin_agent_tools.expose_workflows(
|
|
68
|
+
# order_cancel_workflow: 'write_orders',
|
|
69
|
+
# product_activate_workflow: 'write_products'
|
|
70
|
+
# )
|
|
71
|
+
#
|
|
72
|
+
# A value may be the permission key on its own, or a hash carrying it
|
|
73
|
+
# alongside `except:` (parameters to hide, such as a deprecated one) and
|
|
74
|
+
# `summary:` (a callable overriding the generated confirmation line).
|
|
75
|
+
#
|
|
76
|
+
# @param exposures [Hash{Symbol => String, Hash}]
|
|
77
|
+
# @return [self]
|
|
78
|
+
def expose_workflows(**exposures)
|
|
79
|
+
exposures.each do |dependency_key, options|
|
|
80
|
+
options = { permission: options } if options.is_a?(String) || options.is_a?(Symbol)
|
|
81
|
+
@exposed_workflows[dependency_key.to_sym] = {
|
|
82
|
+
permission: options.fetch(:permission).to_s,
|
|
83
|
+
except: Array(options[:except]).map(&:to_sym),
|
|
84
|
+
summary: options[:summary]
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
self
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @return [Hash{Symbol => Hash}] every exposed workflow, by dependency key
|
|
91
|
+
attr_reader :exposed_workflows
|
|
92
|
+
|
|
93
|
+
# @return [Array<Class<Spree::AgentTool>>]
|
|
94
|
+
def to_a
|
|
95
|
+
registered_classes + workflow_tool_classes
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Tools this caller may actually use, instantiated for their context.
|
|
99
|
+
# This is what an adapter offers the model — a caller without
|
|
100
|
+
# `write_products` never learns that a price-changing tool exists.
|
|
101
|
+
#
|
|
102
|
+
# @param context [Spree::AgentTools::Context]
|
|
103
|
+
# @return [Array<Spree::AgentTool>]
|
|
104
|
+
def available_for(context)
|
|
105
|
+
to_a.
|
|
106
|
+
select { |tool_class| tool_class.try(:audience).nil? || tool_class.audience == audience }.
|
|
107
|
+
map { |tool_class| tool_class.new(context) }.
|
|
108
|
+
select(&:permitted?)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def registered_classes
|
|
114
|
+
@tool_names.filter_map do |name|
|
|
115
|
+
name.safe_constantize.tap do |klass|
|
|
116
|
+
Rails.logger.warn("[Spree] registered agent tool #{name} does not exist") if klass.nil?
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# One generated tool class per exposed workflow whose dependency key
|
|
122
|
+
# still resolves. A key an extension removed simply stops being offered
|
|
123
|
+
# rather than breaking the whole catalog.
|
|
124
|
+
def workflow_tool_classes
|
|
125
|
+
@exposed_workflows.filter_map do |dependency_key, options|
|
|
126
|
+
Spree::AgentTools::WorkflowTool.for(dependency_key, **options)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module AgentTools
|
|
3
|
+
# What the generic tools are allowed to look at.
|
|
4
|
+
#
|
|
5
|
+
# Not a hand-written list: the Admin API's controllers already declare
|
|
6
|
+
# their model, serializer and permission scope, so the map is derived from
|
|
7
|
+
# them — `spree_api` registers one entry per admin resource controller
|
|
8
|
+
# after the classes load. Core owns the structure and the refusal below;
|
|
9
|
+
# a resource no controller serves is not reachable by any agent.
|
|
10
|
+
#
|
|
11
|
+
# Permissions therefore mirror the controllers by construction rather than
|
|
12
|
+
# by convention: the catalog has no `read_price_lists`, and the price-lists
|
|
13
|
+
# controller rides `read_products`, so the derived entry does too.
|
|
14
|
+
class ResourceMap
|
|
15
|
+
# A registered resource whose model has no store scoping. Raised rather
|
|
16
|
+
# than quietly leaking another store's records.
|
|
17
|
+
UnscopedResourceError = Class.new(StandardError)
|
|
18
|
+
|
|
19
|
+
Entry = Struct.new(:key, :model_name, :permission, :serializer_name,
|
|
20
|
+
:dashboard_path, :scope_name, :write_permission,
|
|
21
|
+
:writable_attributes, :create_workflow_key, :update_workflow_key,
|
|
22
|
+
:distinct, :api_paths, keyword_init: true) do
|
|
23
|
+
# The Admin API path for one of this resource's operations, which is
|
|
24
|
+
# how a tool reaches it.
|
|
25
|
+
#
|
|
26
|
+
# @param action [Symbol] :index, :show, :create, :update, :destroy
|
|
27
|
+
# @return [String, nil]
|
|
28
|
+
def api_path(action)
|
|
29
|
+
Array(api_paths).to_h[action.to_sym]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [Boolean] whether a tool can dispatch to this resource
|
|
33
|
+
def dispatchable?
|
|
34
|
+
api_path(:index).present?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Whether a search may add DISTINCT.
|
|
38
|
+
#
|
|
39
|
+
# Postgres rejects SELECT DISTINCT beside an ORDER BY the select list
|
|
40
|
+
# does not carry, which is why some controllers turn it off. Carried
|
|
41
|
+
# from the controller rather than decided here, so a resource that
|
|
42
|
+
# changes its mind is followed without a second list to maintain.
|
|
43
|
+
#
|
|
44
|
+
# @return [Boolean]
|
|
45
|
+
def distinct?
|
|
46
|
+
distinct.nil? ? true : distinct
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Whether the generic write tools may touch this resource.
|
|
50
|
+
#
|
|
51
|
+
# A declared workflow no longer excludes it: the generic writes
|
|
52
|
+
# dispatch to the controller, which runs its own workflow, so there
|
|
53
|
+
# is still exactly one way to write each thing and this is it.
|
|
54
|
+
# What is left to answer is whether the endpoint accepts a body at
|
|
55
|
+
# all.
|
|
56
|
+
#
|
|
57
|
+
# @return [Boolean]
|
|
58
|
+
def generic_writes?
|
|
59
|
+
write_permission.present?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The attributes the Admin API would accept on a write, so the generic
|
|
63
|
+
# tools offer exactly the same surface as the endpoint.
|
|
64
|
+
#
|
|
65
|
+
# @return [Array<String>]
|
|
66
|
+
def writable_attribute_names
|
|
67
|
+
Array(writable_attributes).map(&:to_s).sort
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The associations the show operation will include when asked, read
|
|
71
|
+
# from the serializer that renders it.
|
|
72
|
+
#
|
|
73
|
+
# A record's associations are left out by default because a full
|
|
74
|
+
# admin payload is more than a model's context should hold. Without
|
|
75
|
+
# knowing the names, a model cannot ask for the one it needs — it
|
|
76
|
+
# fetched a product and saw no variants at all.
|
|
77
|
+
#
|
|
78
|
+
# @return [Array<String>]
|
|
79
|
+
def expandable_associations
|
|
80
|
+
@expandable_associations ||= begin
|
|
81
|
+
# Walked up the chain rather than read off one file: a serializer
|
|
82
|
+
# inherits most of its conditional associations from the Store
|
|
83
|
+
# API one it extends.
|
|
84
|
+
classes = Array(serializer_class&.ancestors).select do |ancestor|
|
|
85
|
+
ancestor.is_a?(Class) && ancestor.name.to_s.include?('Serializer')
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
classes.flat_map { |klass| expand_names_in(klass) }.uniq.sort
|
|
89
|
+
rescue StandardError
|
|
90
|
+
[]
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def expand_names_in(klass)
|
|
95
|
+
file = Object.const_source_location(klass.name)&.first
|
|
96
|
+
return [] if file.nil? || !File.exist?(file)
|
|
97
|
+
|
|
98
|
+
File.read(file).scan(/expand\?\('([a-z_]+)'\)/).flatten
|
|
99
|
+
rescue StandardError
|
|
100
|
+
[]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The shape of each attribute that carries a list of records — a
|
|
104
|
+
# product's `variants`, an order's `items`.
|
|
105
|
+
#
|
|
106
|
+
# A model that is only told the key exists sends the records one at a
|
|
107
|
+
# time to their own endpoints instead, which costs several round trips
|
|
108
|
+
# and, where the parent reconciles its list, leaves behind whatever
|
|
109
|
+
# the first write created.
|
|
110
|
+
#
|
|
111
|
+
# @return [Hash{String => Array<String>}] attribute => the keys of one row
|
|
112
|
+
def nested_collection_shapes
|
|
113
|
+
return {} unless defined?(Spree::Api::AgentWriteSchemas)
|
|
114
|
+
|
|
115
|
+
Spree::Api::AgentWriteSchemas.for(key).each_with_object({}) do |(name, schema), shapes|
|
|
116
|
+
next unless schema.is_a?(Hash) && schema['type'] == 'array'
|
|
117
|
+
|
|
118
|
+
# A row's shape is published as a component too, so the array
|
|
119
|
+
# points at one rather than describing its items inline.
|
|
120
|
+
row = Spree::Api::AgentWriteSchemas.resolve_schema(schema['items'])
|
|
121
|
+
properties = row&.dig('properties')
|
|
122
|
+
next unless properties.is_a?(Hash) && properties.any?
|
|
123
|
+
|
|
124
|
+
# Names alone left an agent to guess: it sent a number where the
|
|
125
|
+
# endpoint wanted a decimal string, and omitted a field the model
|
|
126
|
+
# requires, learning both from a 422. The type and the
|
|
127
|
+
# requirement are already in the published schema.
|
|
128
|
+
required = Array(row['required'])
|
|
129
|
+
shapes[name.to_s] = properties.keys.sort.map do |field|
|
|
130
|
+
{ 'name' => field,
|
|
131
|
+
'type' => properties.dig(field, 'type'),
|
|
132
|
+
'required' => required.include?(field) }
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
rescue StandardError
|
|
136
|
+
{}
|
|
137
|
+
end
|
|
138
|
+
# Where this record lives in the dashboard, so an answer links to it
|
|
139
|
+
# rather than describing where to look. Store-relative — the panel
|
|
140
|
+
# prefixes the current store segment.
|
|
141
|
+
#
|
|
142
|
+
# @param record [ActiveRecord::Base]
|
|
143
|
+
# @return [String, nil]
|
|
144
|
+
def dashboard_path_for(record)
|
|
145
|
+
return if dashboard_path.blank?
|
|
146
|
+
|
|
147
|
+
format(dashboard_path, id: record.prefixed_id)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# @return [Class]
|
|
151
|
+
def model_class
|
|
152
|
+
model_name.constantize
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Records of this kind the asking admin may see.
|
|
156
|
+
#
|
|
157
|
+
# Takes the context rather than the store so the ability filter cannot
|
|
158
|
+
# be skipped by forgetting to apply it: store scoping answers tenancy,
|
|
159
|
+
# `accessible` answers which records within that tenant — the same two
|
|
160
|
+
# steps an Admin API controller takes.
|
|
161
|
+
#
|
|
162
|
+
# @param context [Spree::AgentTools::Context]
|
|
163
|
+
# @param action [Symbol]
|
|
164
|
+
# @return [ActiveRecord::Relation]
|
|
165
|
+
def scope_for(context, action = :show)
|
|
166
|
+
relation = model_class.for_store(context.store)
|
|
167
|
+
|
|
168
|
+
# `Spree::Base.for_store` falls back to the bare class when a model
|
|
169
|
+
# has no matching Store association, returning EVERY store's rows.
|
|
170
|
+
# A model that defines its own `for_store` has made a deliberate
|
|
171
|
+
# choice (customers are global by design, and Spree::Customer says
|
|
172
|
+
# so); inheriting the fallback is an accident. Only the accident is
|
|
173
|
+
# refused, because serving another store's records to an assistant
|
|
174
|
+
# that reads them aloud is worse than a missing resource.
|
|
175
|
+
if model_class.method(:for_store).owner == Spree::Base.singleton_class && relation.equal?(model_class)
|
|
176
|
+
raise UnscopedResourceError, key
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# A resource that is a slice of a model rather than the whole of it
|
|
180
|
+
# (draft orders among orders) narrows further.
|
|
181
|
+
relation = relation.public_send(scope_name) if scope_name.present?
|
|
182
|
+
|
|
183
|
+
context.accessible(relation, action)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Whether this resource's scoping is deliberate — either a real
|
|
187
|
+
# store relation, or a model that owns its `for_store` and has decided
|
|
188
|
+
# it is global.
|
|
189
|
+
#
|
|
190
|
+
# @return [Boolean]
|
|
191
|
+
def store_scoped?
|
|
192
|
+
model_class.method(:for_store).owner != Spree::Base.singleton_class ||
|
|
193
|
+
!model_class.for_store(Spree::Store.new).equal?(model_class)
|
|
194
|
+
rescue StandardError
|
|
195
|
+
false
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# @return [Class] the admin serializer for this resource
|
|
199
|
+
def serializer_class
|
|
200
|
+
serializer_name.constantize
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Fields the model actually allows filtering and sorting on. Reused from
|
|
204
|
+
# the model's own Ransack allowlist so the assistant can never query a
|
|
205
|
+
# field the API would refuse.
|
|
206
|
+
#
|
|
207
|
+
# @return [Array<String>]
|
|
208
|
+
def filterable_fields
|
|
209
|
+
model_class.ransackable_attributes.sort
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Named queries the model exposes — `in_stock`, `out_of_stock`,
|
|
213
|
+
# `price_lte` and so on. These answer the questions merchants actually
|
|
214
|
+
# ask ("what's out of stock?") and cannot be expressed as an attribute
|
|
215
|
+
# filter, because the answer lives across stock levels rather than in a
|
|
216
|
+
# column. Passed as bare keys rather than `field_predicate` pairs.
|
|
217
|
+
#
|
|
218
|
+
# @return [Array<String>]
|
|
219
|
+
def filterable_scopes
|
|
220
|
+
model_class.ransackable_scopes.map(&:to_s).sort
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
class << self
|
|
226
|
+
# How the map fills itself the first time it is read. `spree_api` sets
|
|
227
|
+
# this to its controller walk; core owns only the structure, so an
|
|
228
|
+
# installation without the API engine simply has an empty map.
|
|
229
|
+
#
|
|
230
|
+
# @return [#call, nil]
|
|
231
|
+
attr_accessor :deriver
|
|
232
|
+
|
|
233
|
+
# @return [Array<Entry>] every registered resource, in registration order
|
|
234
|
+
def all
|
|
235
|
+
derive!
|
|
236
|
+
entries.values
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Registers one resource. Called by `spree_api` for each admin resource
|
|
240
|
+
# controller; an extension with its own admin controller is picked up
|
|
241
|
+
# by the same walk, so nothing registers by hand.
|
|
242
|
+
#
|
|
243
|
+
# Re-registering a key replaces it, because the walk runs again on every
|
|
244
|
+
# code reload in development.
|
|
245
|
+
#
|
|
246
|
+
# @param key [String, Symbol] the resource key agents address it by
|
|
247
|
+
# @param model_name [String]
|
|
248
|
+
# @param permission [String] the `read_*` key gating it
|
|
249
|
+
# @param serializer_name [String]
|
|
250
|
+
# @param dashboard_path [String, nil] a `%<id>s` format string
|
|
251
|
+
# @param scope_name [Symbol, nil] narrows the model to a slice of itself
|
|
252
|
+
# @param write_permission [String, nil] the `write_*` key, absent when
|
|
253
|
+
# the controller offers no writes
|
|
254
|
+
# @param writable_attributes [Array<Symbol>] what a write may set
|
|
255
|
+
# @param create_workflow_key [String, nil] set when the controller
|
|
256
|
+
# declares a create workflow, which the generic write defers to
|
|
257
|
+
# @param update_workflow_key [String, nil] likewise for updates
|
|
258
|
+
# @param distinct [Boolean, nil] false where the controller disables
|
|
259
|
+
# DISTINCT on its own collection, which Postgres requires when the
|
|
260
|
+
# sort column is not in the select list
|
|
261
|
+
# @return [Entry]
|
|
262
|
+
def register(key:, model_name:, permission:, serializer_name:, dashboard_path: nil, scope_name: nil,
|
|
263
|
+
write_permission: nil, writable_attributes: [], create_workflow_key: nil,
|
|
264
|
+
update_workflow_key: nil, distinct: nil, api_paths: nil)
|
|
265
|
+
entry = Entry.new(key: key.to_s, model_name: model_name, permission: permission,
|
|
266
|
+
serializer_name: serializer_name, dashboard_path: dashboard_path,
|
|
267
|
+
scope_name: scope_name, write_permission: write_permission,
|
|
268
|
+
writable_attributes: writable_attributes,
|
|
269
|
+
create_workflow_key: create_workflow_key,
|
|
270
|
+
update_workflow_key: update_workflow_key,
|
|
271
|
+
distinct: distinct,
|
|
272
|
+
api_paths: api_paths&.to_a)
|
|
273
|
+
entries[entry.key] = entry
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Forgets every registration, so the derivation can run again from
|
|
277
|
+
# scratch on a code reload.
|
|
278
|
+
#
|
|
279
|
+
# @return [void]
|
|
280
|
+
def reset!
|
|
281
|
+
@entries = {}
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# @param key [String]
|
|
285
|
+
# @return [Entry, nil]
|
|
286
|
+
def find(key)
|
|
287
|
+
derive!
|
|
288
|
+
entries[key.to_s]
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Marks the map for rebuilding on its next read. Called on every code
|
|
292
|
+
# reload, because a controller edited in development changes what the
|
|
293
|
+
# map should say — but the rebuild itself waits for a reader.
|
|
294
|
+
#
|
|
295
|
+
# @return [void]
|
|
296
|
+
def stale!
|
|
297
|
+
@derived = false
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Fills the map once, from whatever `deriver` was registered.
|
|
301
|
+
#
|
|
302
|
+
# Under a mutex, because the deriver rebuilds in place — it clears the
|
|
303
|
+
# map and registers each entry — so a second thread reading between
|
|
304
|
+
# those two moments would see an empty map and report every resource
|
|
305
|
+
# as unknown.
|
|
306
|
+
#
|
|
307
|
+
# The flag is cleared again if the deriver raises: the map is empty by
|
|
308
|
+
# then, and leaving it marked derived would answer "unknown resource"
|
|
309
|
+
# for the rest of the process, since `stale!` only runs at boot and on
|
|
310
|
+
# reload. A failed derivation is retried by the next caller instead.
|
|
311
|
+
#
|
|
312
|
+
# @return [void]
|
|
313
|
+
def derive!
|
|
314
|
+
return if @derived || deriver.nil?
|
|
315
|
+
|
|
316
|
+
derive_mutex.synchronize do
|
|
317
|
+
return if @derived
|
|
318
|
+
|
|
319
|
+
# Set before calling, so a deriver that reads the map while filling
|
|
320
|
+
# it does not recurse.
|
|
321
|
+
@derived = true
|
|
322
|
+
begin
|
|
323
|
+
deriver.call
|
|
324
|
+
rescue StandardError
|
|
325
|
+
@derived = false
|
|
326
|
+
raise
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Resources this caller may read, so a tool description lists only what
|
|
332
|
+
# they can actually ask about.
|
|
333
|
+
#
|
|
334
|
+
# @param context [Spree::AgentTools::Context]
|
|
335
|
+
# @return [Array<Entry>]
|
|
336
|
+
def available_for(context)
|
|
337
|
+
all.select { |entry| context.permitted?(entry.permission) }
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
private
|
|
341
|
+
|
|
342
|
+
def entries
|
|
343
|
+
@entries ||= {}
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def derive_mutex
|
|
347
|
+
@derive_mutex ||= Mutex.new
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module AgentTools
|
|
3
|
+
# Which Admin API operation runs each workflow an agent may call.
|
|
4
|
+
#
|
|
5
|
+
# A contract like {ResourceMap} and {MembershipMap}: core declares the
|
|
6
|
+
# shape, and `spree_api` fills it by reading its own routes and
|
|
7
|
+
# controllers, so a workflow that gains an endpoint is dispatched without
|
|
8
|
+
# anyone editing a list.
|
|
9
|
+
#
|
|
10
|
+
# A workflow with no entry runs in this process instead. That is not a
|
|
11
|
+
# fallback to be relied on — it means the capability has no endpoint yet,
|
|
12
|
+
# and the tool is the only way to reach it.
|
|
13
|
+
module WorkflowEndpoints
|
|
14
|
+
# @!attribute verb
|
|
15
|
+
# @return [String] 'POST', 'PATCH'
|
|
16
|
+
# @!attribute path
|
|
17
|
+
# @return [String] with `:name` segments still in it
|
|
18
|
+
# @!attribute body_keys
|
|
19
|
+
# @return [Array<String>] what the operation's request body accepts,
|
|
20
|
+
# read from the generated OpenAPI document — which is where the
|
|
21
|
+
# renames live (a cancellation's `reason:` keyword is
|
|
22
|
+
# `cancel_reason_id` on the wire)
|
|
23
|
+
Entry = Struct.new(:verb, :path, :body_keys, keyword_init: true) do
|
|
24
|
+
# The dynamic segments, in the order the path names them.
|
|
25
|
+
#
|
|
26
|
+
# @return [Array<String>]
|
|
27
|
+
def path_params
|
|
28
|
+
@path_params ||= path.scan(/:([a-z_]+)/).flatten
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param values [Hash] path parameter name => id
|
|
32
|
+
# @return [String]
|
|
33
|
+
def path_for(values)
|
|
34
|
+
path.gsub(/:([a-z_]+)/) { values.fetch(Regexp.last_match(1)).to_s }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
# @param key [Symbol] a `Spree::Dependencies` workflow key
|
|
40
|
+
# @return [Entry, nil]
|
|
41
|
+
def find(key)
|
|
42
|
+
derive!
|
|
43
|
+
entries[key.to_sym]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @return [Hash{Symbol => Entry}]
|
|
47
|
+
def all
|
|
48
|
+
derive!
|
|
49
|
+
entries
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def register(key:, verb:, path:, body_keys: [])
|
|
53
|
+
entries[key.to_sym] = Entry.new(verb: verb.to_s.upcase, path: path,
|
|
54
|
+
body_keys: body_keys.map(&:to_s))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def reset!
|
|
58
|
+
@entries = {}
|
|
59
|
+
@derived = false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# What has been registered so far, without deriving — for the
|
|
63
|
+
# deriver itself, which must not ask for what it is producing.
|
|
64
|
+
#
|
|
65
|
+
# @return [Hash{Symbol => Entry}]
|
|
66
|
+
def registered
|
|
67
|
+
entries
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Filled on first read rather than at boot: deriving it loads every
|
|
71
|
+
# admin controller, which a developer who never runs an agent should
|
|
72
|
+
# not pay for on each code reload.
|
|
73
|
+
def derive!
|
|
74
|
+
return if @derived
|
|
75
|
+
|
|
76
|
+
@derived = true
|
|
77
|
+
Spree::Api::AgentWorkflowEndpoints.install if defined?(Spree::Api::AgentWorkflowEndpoints)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def entries
|
|
83
|
+
@entries ||= {}
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|