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