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,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
|