spree_core 5.6.0 → 5.6.1
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/jobs/spree/imports/process_group_job.rb +45 -26
- data/app/jobs/spree/imports/process_rows_job.rb +10 -2
- data/app/models/concerns/spree/metafield_filterable.rb +162 -0
- data/app/models/concerns/spree/stores/setup.rb +6 -1
- data/app/models/spree/base.rb +35 -0
- data/app/models/spree/channel/gating.rb +4 -4
- data/app/models/spree/export.rb +14 -6
- data/app/models/spree/import.rb +92 -0
- data/app/models/spree/metafield.rb +11 -0
- data/app/models/spree/metafield_definition/search_capabilities.rb +104 -0
- data/app/models/spree/metafield_definition.rb +2 -1
- data/app/models/spree/metafields/long_text.rb +4 -0
- data/app/models/spree/metafields/number.rb +8 -0
- data/app/models/spree/metafields/short_text.rb +8 -0
- data/app/models/spree/product.rb +19 -1
- data/app/models/spree/report.rb +11 -1
- data/app/models/spree/search_provider/base.rb +5 -0
- data/app/models/spree/search_provider/database.rb +49 -3
- data/app/models/spree/search_provider/meilisearch.rb +63 -8
- data/app/presenters/spree/search_provider/product_presenter.rb +21 -1
- data/app/services/spree/imports/row_processors/product_variant.rb +6 -1
- data/app/services/spree/sample_data/helper.rb +55 -0
- data/app/services/spree/sample_data/import_builder.rb +32 -0
- data/app/services/spree/sample_data/import_runner.rb +23 -42
- data/app/services/spree/sample_data/loader.rb +6 -57
- data/app/services/spree/search_provider/metafield_schema.rb +144 -0
- data/app/services/spree/seeds/allowed_origins.rb +3 -4
- data/app/services/spree/seeds/states.rb +35 -16
- data/config/locales/en.yml +6 -0
- data/db/migrate/20260722000001_add_searchable_sortable_to_spree_metafield_definitions.rb +6 -0
- data/db/migrate/20260727000001_add_unique_country_abbr_index_to_spree_states.rb +30 -0
- data/lib/spree/core/preferences/preferable.rb +7 -1
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/permitted_attributes.rb +5 -1
- data/lib/spree/testing_support/factories/metafield_definition_factory.rb +10 -0
- data/lib/tasks/product_tag_tenants.rake +1 -3
- metadata +11 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 102686daa285991fed8a91b768781fb7a3433411ffbc854cd2ef758c8c8687c7
|
|
4
|
+
data.tar.gz: 3c17b1e08025e190848c4699a5e6bbef00a66314b91054092d44c22971e3dbd2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 24354241aa89fcd8129c48e6ffacb1325334a17d6bc65d7481e3a8c0a7816365f7a73d9fd853831bcbe20a4373697be39c8e49f666d41d52409bd78d170575c1
|
|
7
|
+
data.tar.gz: 7a11fb6dd5229cab6f45019d3ba94b2820c1a00a477ce13efdf9bf5e0af819cc40df37179caded829a169706de110bcf5cebdc8371dee704fe1d9cbaaf897750
|
|
@@ -25,6 +25,12 @@ module Spree
|
|
|
25
25
|
import = Spree::Import.find(import_id)
|
|
26
26
|
Spree::Current.store = import.store
|
|
27
27
|
|
|
28
|
+
process_group(import, row_ids)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def process_group(import, row_ids)
|
|
28
34
|
with_store_content_locale(import.store) do
|
|
29
35
|
mappings = import.mappings.mapped.to_a
|
|
30
36
|
schema_fields = import.schema_fields
|
|
@@ -33,40 +39,53 @@ module Spree
|
|
|
33
39
|
started_at = Time.current
|
|
34
40
|
processed_rows = []
|
|
35
41
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
42
|
+
skipping_row_events(import) do
|
|
43
|
+
row_ids.each_slice(ROWS_BATCH_SIZE) do |ids|
|
|
44
|
+
# Skip rows already completed on a prior attempt so retries don't double-process them.
|
|
45
|
+
rows = import.rows.where(id: ids).pending_and_failed.order(:row_number).to_a
|
|
46
|
+
# Share the already-loaded import across rows: each row's processor reads
|
|
47
|
+
# `row.import` (store, ability, lookup cache), and without this every row
|
|
48
|
+
# lazily loads its own Import instance and rebuilds all of that per row.
|
|
49
|
+
rows.each { |row| row.association(:import).target = import }
|
|
50
|
+
|
|
51
|
+
if large
|
|
52
|
+
Spree::Events.disable do
|
|
53
|
+
rows.each { |row| row.bulk_process!(mappings: mappings, schema_fields: schema_fields) }
|
|
54
|
+
end
|
|
55
|
+
elsif grouped
|
|
56
|
+
# A group is one product plus its variants: per-record lifecycle
|
|
57
|
+
# events (variant.created, price.created, product.updated per
|
|
58
|
+
# touch) are noise to subscribers — one product event is published
|
|
59
|
+
# for the whole group below. import_row.* events still flow.
|
|
60
|
+
Spree::Events.disable_lifecycle do
|
|
61
|
+
rows.each { |row| row.process!(mappings: mappings, schema_fields: schema_fields) }
|
|
62
|
+
end
|
|
63
|
+
else
|
|
64
|
+
rows.each do |row|
|
|
65
|
+
row.process!(mappings: mappings, schema_fields: schema_fields)
|
|
66
|
+
end
|
|
59
67
|
end
|
|
68
|
+
processed_rows.concat(rows)
|
|
60
69
|
end
|
|
61
|
-
|
|
70
|
+
|
|
71
|
+
publish_group_events(processed_rows, started_at) if grouped
|
|
62
72
|
end
|
|
63
73
|
|
|
64
|
-
|
|
74
|
+
# Outside `skipping_row_events` on purpose: the import's own
|
|
75
|
+
# lifecycle (`import.progress`, `import.completed`) is a handful of
|
|
76
|
+
# events per run and stays observable even for seeded data.
|
|
65
77
|
check_import_completion(import, large)
|
|
66
78
|
end
|
|
67
79
|
end
|
|
68
80
|
|
|
69
|
-
|
|
81
|
+
# Seeded catalogs (sample/demo data) shouldn't reach webhooks or
|
|
82
|
+
# analytics. Scoped to the rows: the import's own lifecycle events stay
|
|
83
|
+
# observable.
|
|
84
|
+
def skipping_row_events(import, &block)
|
|
85
|
+
return Spree::Events.disable(&block) if import.preferred_skip_events
|
|
86
|
+
|
|
87
|
+
block.call
|
|
88
|
+
end
|
|
70
89
|
|
|
71
90
|
# One event per product touched by this group: `product.created` when the
|
|
72
91
|
# product came into existence during this run, `product.updated` otherwise
|
|
@@ -23,6 +23,14 @@ module Spree
|
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
# Inline callers need the group finished before `perform` returns; the
|
|
27
|
+
# group job's completion bookkeeping works either way.
|
|
28
|
+
def dispatch_group(import, row_ids)
|
|
29
|
+
return ProcessGroupJob.perform_now(import.id, row_ids) if import.preferred_inline
|
|
30
|
+
|
|
31
|
+
ProcessGroupJob.perform_later(import.id, row_ids)
|
|
32
|
+
end
|
|
33
|
+
|
|
26
34
|
def dispatch_grouped(import, file_column)
|
|
27
35
|
groups = Hash.new { |h, k| h[k] = [] }
|
|
28
36
|
|
|
@@ -48,7 +56,7 @@ module Spree
|
|
|
48
56
|
updated_at: Time.current
|
|
49
57
|
)
|
|
50
58
|
|
|
51
|
-
batches.each { |row_ids|
|
|
59
|
+
batches.each { |row_ids| dispatch_group(import, row_ids) }
|
|
52
60
|
end
|
|
53
61
|
|
|
54
62
|
def dispatch_batched(import)
|
|
@@ -64,7 +72,7 @@ module Spree
|
|
|
64
72
|
updated_at: Time.current
|
|
65
73
|
)
|
|
66
74
|
|
|
67
|
-
row_id_batches.each { |row_ids|
|
|
75
|
+
row_id_batches.each { |row_ids| dispatch_group(import, row_ids) }
|
|
68
76
|
end
|
|
69
77
|
end
|
|
70
78
|
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
# Resolves `cf_*` custom-field predicates (e.g. +cf_custom_material_i_cont+)
|
|
5
|
+
# into scopes. Ransack has no such attributes, so callers split them out of a
|
|
6
|
+
# filter hash with {with_metafield_filters} before handing the rest to
|
|
7
|
+
# Ransack. Lives on the model rather than in a SearchProvider so every
|
|
8
|
+
# consumer — the search providers, CSV export, any other Ransack entry
|
|
9
|
+
# point — resolves the same vocabulary.
|
|
10
|
+
module MetafieldFilterable
|
|
11
|
+
extend ActiveSupport::Concern
|
|
12
|
+
|
|
13
|
+
# Arel comparison methods for numeric predicates.
|
|
14
|
+
NUMERIC_OPERATORS = {
|
|
15
|
+
'eq' => :eq,
|
|
16
|
+
'gt' => :gt,
|
|
17
|
+
'gteq' => :gteq,
|
|
18
|
+
'lt' => :lt,
|
|
19
|
+
'lteq' => :lteq
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
# Wraps a value in the LIKE pattern its predicate implies.
|
|
23
|
+
LIKE_PATTERNS = {
|
|
24
|
+
'cont' => ->(value) { "%#{value}%" },
|
|
25
|
+
'i_cont' => ->(value) { "%#{value}%" },
|
|
26
|
+
'start' => ->(value) { "#{value}%" },
|
|
27
|
+
'end' => ->(value) { "%#{value}" }
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
class_methods do
|
|
31
|
+
# Applies every `cf_*` predicate in +filters+ and returns the resulting
|
|
32
|
+
# scope alongside the filters Ransack should still handle. Predicates
|
|
33
|
+
# that don't apply (unknown key, wrong type, unparseable value) are
|
|
34
|
+
# dropped rather than raising, so a stale bookmarked filter degrades to
|
|
35
|
+
# "no filter" instead of a 500.
|
|
36
|
+
#
|
|
37
|
+
# @param filters [Hash]
|
|
38
|
+
# @param schema [Spree::SearchProvider::MetafieldSchema, nil]
|
|
39
|
+
# @return [Array(ActiveRecord::Relation, Hash)] scoped relation + remaining filters
|
|
40
|
+
def with_metafield_filters(filters, schema: nil)
|
|
41
|
+
return [all, filters] if filters.blank?
|
|
42
|
+
# Skip the schema's DB query entirely for the common case of a filter
|
|
43
|
+
# hash with no custom-field predicates at all.
|
|
44
|
+
return [all, filters] unless filters.any? { |key, _| key.to_s.start_with?('cf_') }
|
|
45
|
+
|
|
46
|
+
schema ||= Spree::SearchProvider::MetafieldSchema.new
|
|
47
|
+
scope = all
|
|
48
|
+
|
|
49
|
+
remaining = filters.reject do |key, value|
|
|
50
|
+
parsed = schema.parse_filter(key)
|
|
51
|
+
next false unless parsed
|
|
52
|
+
|
|
53
|
+
condition = metafield_filter_condition(parsed[:definition], parsed[:predicate], value)
|
|
54
|
+
scope = scope.where(condition) if condition
|
|
55
|
+
true
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
[scope, remaining]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# One EXISTS / NOT EXISTS subquery per predicate — composes with other
|
|
62
|
+
# filters without duplicating rows the way a join would.
|
|
63
|
+
#
|
|
64
|
+
# @param definition [Spree::MetafieldDefinition]
|
|
65
|
+
# @param predicate [String]
|
|
66
|
+
# @param value [Object]
|
|
67
|
+
# @return [Arel::Nodes::Node, nil] nil when the value is unusable
|
|
68
|
+
def metafield_filter_condition(definition, predicate, value)
|
|
69
|
+
case predicate
|
|
70
|
+
when 'present'
|
|
71
|
+
metafield_exists(definition)
|
|
72
|
+
when 'blank'
|
|
73
|
+
metafield_exists(definition).not
|
|
74
|
+
else
|
|
75
|
+
value_condition = metafield_value_condition(definition.field_type, predicate, value)
|
|
76
|
+
value_condition && metafield_exists(definition, value_condition)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Type-casts a metafield value column so numeric fields compare
|
|
81
|
+
# numerically rather than lexicographically. Public because metafield
|
|
82
|
+
# sorting in Spree::SearchProvider::Database applies the same cast to a
|
|
83
|
+
# differently-aliased join.
|
|
84
|
+
#
|
|
85
|
+
# @param column [Arel::Attributes::Attribute] value column
|
|
86
|
+
# @param field_type [String]
|
|
87
|
+
# @return [Arel::Nodes::Node] castable expression
|
|
88
|
+
def metafield_value_expression(column, field_type)
|
|
89
|
+
return column unless field_type == 'number'
|
|
90
|
+
|
|
91
|
+
Arel::Nodes::NamedFunction.new('CAST', [column.as(metafield_numeric_cast_type)])
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
# `CAST(... AS <type>)` target for numeric metafield comparisons. SQLite
|
|
97
|
+
# has no DECIMAL affinity and PostgreSQL rejects MySQL's precision form,
|
|
98
|
+
# so the type is per-adapter.
|
|
99
|
+
def metafield_numeric_cast_type
|
|
100
|
+
case connection.adapter_name
|
|
101
|
+
when /PostgreSQL/i then 'numeric'
|
|
102
|
+
when /Mysql|Trilogy/i then 'DECIMAL(30, 10)'
|
|
103
|
+
else 'REAL'
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# `EXISTS (SELECT 1 FROM spree_metafields WHERE …)` correlated against
|
|
108
|
+
# this model's rows, optionally narrowed by a value condition.
|
|
109
|
+
def metafield_exists(definition, value_condition = nil)
|
|
110
|
+
metafields = metafield_filter_table
|
|
111
|
+
subquery = Arel::SelectManager.new(metafields).
|
|
112
|
+
project(Arel.sql('1')).
|
|
113
|
+
where(metafields[:resource_type].eq(name)).
|
|
114
|
+
where(metafields[:resource_id].eq(arel_table[primary_key])).
|
|
115
|
+
where(metafields[:metafield_definition_id].eq(definition.id))
|
|
116
|
+
|
|
117
|
+
subquery = subquery.where(value_condition) if value_condition
|
|
118
|
+
|
|
119
|
+
# `.ast`, not the SelectManager — Exists adds its own parentheses, and
|
|
120
|
+
# a manager contributes a second set, yielding invalid `EXISTS ((…))`.
|
|
121
|
+
Arel::Nodes::Exists.new(subquery.ast)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Aliased so the correlated subquery can't collide with an outer join on
|
|
125
|
+
# the same table (metafield sorting joins it as `sort_cf`).
|
|
126
|
+
def metafield_filter_table
|
|
127
|
+
@metafield_filter_table ||= Spree::Metafield.arel_table.alias('filter_cf')
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def metafield_value_condition(field_type, predicate, value)
|
|
131
|
+
column = metafield_filter_table[:value]
|
|
132
|
+
|
|
133
|
+
if field_type == 'number'
|
|
134
|
+
operator = NUMERIC_OPERATORS[predicate]
|
|
135
|
+
return nil unless operator
|
|
136
|
+
|
|
137
|
+
numeric = begin
|
|
138
|
+
BigDecimal(value.to_s)
|
|
139
|
+
rescue ArgumentError, TypeError
|
|
140
|
+
return nil
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
metafield_value_expression(column, field_type).public_send(operator, numeric)
|
|
144
|
+
elsif (pattern = LIKE_PATTERNS[predicate])
|
|
145
|
+
escaped = sanitize_sql_like(value.to_s)
|
|
146
|
+
# LIKE is already case-insensitive under MySQL's default collation
|
|
147
|
+
# and for ASCII on SQLite, but not on PostgreSQL — lower both sides
|
|
148
|
+
# so `i_cont` behaves the same everywhere.
|
|
149
|
+
if predicate == 'i_cont'
|
|
150
|
+
column.lower.matches(pattern.call(escaped.downcase), nil, true)
|
|
151
|
+
else
|
|
152
|
+
column.matches(pattern.call(escaped), nil, true)
|
|
153
|
+
end
|
|
154
|
+
elsif predicate == 'eq'
|
|
155
|
+
column.eq(value.to_s)
|
|
156
|
+
elsif predicate == 'not_eq'
|
|
157
|
+
column.not_eq(value.to_s)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -52,8 +52,13 @@ module Spree
|
|
|
52
52
|
(setup_tasks_done / setup_tasks_total.to_f * 100).to_i
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
+
# Payments count as set up only once customers can actually pay at
|
|
56
|
+
# storefront checkout: admin-only (back_end) methods and store credit
|
|
57
|
+
# don't complete the task.
|
|
58
|
+
#
|
|
59
|
+
# @return [Boolean]
|
|
55
60
|
def payment_method_setup?
|
|
56
|
-
payment_methods.active.where.not(type: Spree::PaymentMethod::StoreCredit.to_s).any?
|
|
61
|
+
payment_methods.active.storefront_visible.where.not(type: Spree::PaymentMethod::StoreCredit.to_s).any?
|
|
57
62
|
end
|
|
58
63
|
|
|
59
64
|
# A storefront counts as set up once the merchant has saved the storefront
|
data/app/models/spree/base.rb
CHANGED
|
@@ -92,6 +92,41 @@ class Spree::Base < ApplicationRecord
|
|
|
92
92
|
api_type
|
|
93
93
|
end
|
|
94
94
|
|
|
95
|
+
# `.api_type` for an STI `type` column value, without instantiating the
|
|
96
|
+
# subclass — use this in serializers instead of `record.class.api_type`,
|
|
97
|
+
# which reports the *loaded* class and so returns the base type for a record
|
|
98
|
+
# read through the parent (a plain `Spree::Export` row, a factory that sets
|
|
99
|
+
# `type` as an attribute, a query on the base relation).
|
|
100
|
+
#
|
|
101
|
+
# Resolves against `available_types` where the class defines it, so an
|
|
102
|
+
# unrecognized column value passes through untouched rather than being
|
|
103
|
+
# constantized.
|
|
104
|
+
#
|
|
105
|
+
# @param type [String, nil] value of the STI `type` column
|
|
106
|
+
# @return [String]
|
|
107
|
+
def self.api_type_for(type)
|
|
108
|
+
return api_type if type.blank?
|
|
109
|
+
|
|
110
|
+
type = type.to_s
|
|
111
|
+
return type unless respond_to?(:available_types)
|
|
112
|
+
|
|
113
|
+
available_types.find { |klass| klass.to_s == type }&.api_type || type
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Shorthand for a *polymorphic* `*_type` column (`owner_type`,
|
|
117
|
+
# `viewable_type`, …), where the value names an arbitrary model rather than a
|
|
118
|
+
# subclass of the serialized one — so `api_type_for`'s registry lookup does
|
|
119
|
+
# not apply. Demodulizes and underscores the same way `api_type` does, giving
|
|
120
|
+
# `"Spree::Product"` => `"product"`.
|
|
121
|
+
#
|
|
122
|
+
# @param type [String, Class, nil] value of the polymorphic type column
|
|
123
|
+
# @return [String, nil]
|
|
124
|
+
def self.polymorphic_api_type(type)
|
|
125
|
+
return nil if type.blank?
|
|
126
|
+
|
|
127
|
+
type.to_s.demodulize.underscore
|
|
128
|
+
end
|
|
129
|
+
|
|
95
130
|
def self.to_tom_select_json
|
|
96
131
|
pluck(:name, :id).map do |name, id|
|
|
97
132
|
{
|
|
@@ -14,10 +14,10 @@ module Spree
|
|
|
14
14
|
STOREFRONT_ACCESS = %w[public prices_hidden login_required].freeze
|
|
15
15
|
|
|
16
16
|
included do
|
|
17
|
-
# Empty -> falls back to the Store-level preference. `
|
|
18
|
-
#
|
|
19
|
-
# false, while an explicit
|
|
20
|
-
preference :storefront_access, :string, default: nil
|
|
17
|
+
# Empty -> falls back to the Store-level preference. Both are `nullable`
|
|
18
|
+
# so a blank write stays nil (inherit) rather than coercing to "" or
|
|
19
|
+
# false, while an explicit value remains a channel override.
|
|
20
|
+
preference :storefront_access, :string, default: nil, nullable: true
|
|
21
21
|
preference :guest_checkout, :boolean, default: nil, nullable: true
|
|
22
22
|
|
|
23
23
|
validate :storefront_access_must_be_valid
|
data/app/models/spree/export.rb
CHANGED
|
@@ -138,12 +138,20 @@ module Spree
|
|
|
138
138
|
end
|
|
139
139
|
|
|
140
140
|
def records_to_export
|
|
141
|
-
if search_params.
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
141
|
+
return scope.ransack.result if search_params.blank?
|
|
142
|
+
|
|
143
|
+
params = search_params.is_a?(String) ? JSON.parse(search_params.to_s).to_h : search_params
|
|
144
|
+
params = decode_prefixed_id_filters(params)
|
|
145
|
+
|
|
146
|
+
# `cf_*` custom-field predicates aren't Ransack attributes — resolve them
|
|
147
|
+
# first so an export of a filtered list matches what the admin sees.
|
|
148
|
+
filtered_scope, params = if scope.respond_to?(:with_metafield_filters)
|
|
149
|
+
scope.with_metafield_filters(params)
|
|
150
|
+
else
|
|
151
|
+
[scope, params]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
filtered_scope.ransack(params).result
|
|
147
155
|
end
|
|
148
156
|
|
|
149
157
|
# Replace any prefixed IDs in `search_params` with their raw DB IDs so
|
data/app/models/spree/import.rb
CHANGED
|
@@ -7,6 +7,15 @@ module Spree
|
|
|
7
7
|
include Spree::NumberIdentifier
|
|
8
8
|
include Spree::Core::NumberGenerator.new(prefix: 'IM')
|
|
9
9
|
|
|
10
|
+
# Where the downloadable example CSVs are served from — see `sample_csv_url`.
|
|
11
|
+
#
|
|
12
|
+
# Pinned to the installed version's tag rather than `main`: whether a type
|
|
13
|
+
# *has* an example is answered by the local `db/sample_data`, so pointing
|
|
14
|
+
# elsewhere would let a released install link to a newer, incompatible
|
|
15
|
+
# schema (or a file that has since been removed).
|
|
16
|
+
SAMPLE_DATA_BASE_URL_TEMPLATE =
|
|
17
|
+
'https://raw.githubusercontent.com/spree/spree/refs/tags/v%<version>s/spree/core/db/sample_data'.freeze
|
|
18
|
+
|
|
10
19
|
publishes_lifecycle_events
|
|
11
20
|
|
|
12
21
|
# Set event prefix for all Import subclasses
|
|
@@ -88,11 +97,58 @@ module Spree
|
|
|
88
97
|
# Preferences
|
|
89
98
|
#
|
|
90
99
|
preference :delimiter, :string, default: ','
|
|
100
|
+
# Run the pipeline in-process instead of enqueuing it. For rake tasks, seeds
|
|
101
|
+
# and the console, where the caller needs the data to exist when the call
|
|
102
|
+
# returns and there may be no worker attached. Never set it from a request:
|
|
103
|
+
# a large CSV blocks for minutes.
|
|
104
|
+
#
|
|
105
|
+
# A preference, not a virtual attribute, so it survives the `Import.find`
|
|
106
|
+
# each processing job does — otherwise every job would have to take (and
|
|
107
|
+
# forward) an `inline:` argument.
|
|
108
|
+
preference :inline, :boolean, default: false
|
|
109
|
+
# Suppress the events the *rows* publish — a `product.created` per imported
|
|
110
|
+
# product, plus `import_row.*`. The import's own `import.*` events still
|
|
111
|
+
# fire: a handful per import is useful, thousands from a seeded catalog are
|
|
112
|
+
# not.
|
|
113
|
+
#
|
|
114
|
+
# For seeding demo or sample data, where subscribers would otherwise deliver
|
|
115
|
+
# webhooks and analytics for a catalog nobody actually created. A preference
|
|
116
|
+
# rather than a virtual attribute because the processing jobs each reload
|
|
117
|
+
# the record, so it has to outlive the enqueue. Independent of `inline`: a
|
|
118
|
+
# background import can still be silent, and an inline one observed.
|
|
119
|
+
preference :skip_events, :boolean, default: false
|
|
91
120
|
# Absolute URL of the dashboard's imports view, captured at create and
|
|
92
121
|
# validated against the store's allowed origins — the import-done email
|
|
93
122
|
# links back to it. Blank for legacy-admin or app-created imports.
|
|
94
123
|
preference :results_url, :string, default: nil
|
|
95
124
|
|
|
125
|
+
# Publicly downloadable example CSV for this import's type, or nil when the
|
|
126
|
+
# type has no sample file. Resolved from the `type` column rather than the
|
|
127
|
+
# instance's class, so it works on a record built as the base
|
|
128
|
+
# `Spree::Import` with `type` assigned (which is how the admin's new-import
|
|
129
|
+
# form builds it).
|
|
130
|
+
#
|
|
131
|
+
# @return [String, nil]
|
|
132
|
+
def sample_csv_url
|
|
133
|
+
self.class.available_types.find { |klass| klass.to_s == type.to_s }&.sample_csv_url
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Header-only CSV for this import's schema — the blank counterpart to
|
|
137
|
+
# `sample_csv_url`. One line, so callers can inline it (e.g. as a `data:`
|
|
138
|
+
# URI) instead of round-tripping to the server for it.
|
|
139
|
+
#
|
|
140
|
+
# @return [String]
|
|
141
|
+
def template_csv
|
|
142
|
+
::CSV.generate_line(schema_fields.map { |field| field[:name] })
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Suggested filename for `template_csv`, e.g. "products_import_template.csv".
|
|
146
|
+
#
|
|
147
|
+
# @return [String]
|
|
148
|
+
def template_csv_filename
|
|
149
|
+
"#{self.class.api_type_for(type)}_import_template.csv"
|
|
150
|
+
end
|
|
151
|
+
|
|
96
152
|
# Returns true if the import has more rows than the large import threshold.
|
|
97
153
|
# Large imports skip per-row UI broadcasts and use bulk processing.
|
|
98
154
|
# @return [Boolean]
|
|
@@ -289,12 +345,18 @@ module Spree
|
|
|
289
345
|
# Creates rows asynchronously
|
|
290
346
|
# @return [void]
|
|
291
347
|
def create_rows_async
|
|
348
|
+
# The 2s delay lets the attachment settle before the job reads it; running
|
|
349
|
+
# inline there is nothing to wait for.
|
|
350
|
+
return Spree::Imports::CreateRowsJob.perform_now(id) if preferred_inline
|
|
351
|
+
|
|
292
352
|
Spree::Imports::CreateRowsJob.set(wait: 2.seconds).perform_later(id)
|
|
293
353
|
end
|
|
294
354
|
|
|
295
355
|
# Processes rows asynchronously
|
|
296
356
|
# @return [void]
|
|
297
357
|
def process_rows_async
|
|
358
|
+
return Spree::Imports::ProcessRowsJob.perform_now(id) if preferred_inline
|
|
359
|
+
|
|
298
360
|
Spree::Imports::ProcessRowsJob.perform_later(id)
|
|
299
361
|
end
|
|
300
362
|
|
|
@@ -360,6 +422,36 @@ module Spree
|
|
|
360
422
|
to_s.demodulize.underscore.to_sym
|
|
361
423
|
end
|
|
362
424
|
|
|
425
|
+
# Publicly downloadable example CSV for this import type — the same file
|
|
426
|
+
# `rake spree:load_sample_data` feeds through this very pipeline, so it is
|
|
427
|
+
# a working example rather than a hand-maintained sample that can drift
|
|
428
|
+
# from the schema.
|
|
429
|
+
#
|
|
430
|
+
# Whether a type *has* an example is answered by `db/sample_data` itself,
|
|
431
|
+
# so adding or removing a CSV there needs no change here; a type with no
|
|
432
|
+
# sample file returns nil and the UI omits the link. The URL is pinned to
|
|
433
|
+
# the installed version's tag so the file served always matches the schema
|
|
434
|
+
# that check was made against.
|
|
435
|
+
#
|
|
436
|
+
# @return [String, nil]
|
|
437
|
+
def sample_csv_url
|
|
438
|
+
return nil if self == Spree::Import
|
|
439
|
+
return nil unless Spree::Core::Engine.root.join('db', 'sample_data', sample_csv_filename).exist?
|
|
440
|
+
|
|
441
|
+
[sample_data_base_url, sample_csv_filename].join('/')
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# @return [String]
|
|
445
|
+
def sample_data_base_url
|
|
446
|
+
format(SAMPLE_DATA_BASE_URL_TEMPLATE, version: Spree.version)
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
# eg. Spree::Imports::ProductTranslations => "product_translations.csv"
|
|
450
|
+
# @return [String]
|
|
451
|
+
def sample_csv_filename
|
|
452
|
+
"#{api_type}.csv"
|
|
453
|
+
end
|
|
454
|
+
|
|
363
455
|
# eg. Spree::Imports::Orders => Spree::Order
|
|
364
456
|
def model_class
|
|
365
457
|
return Spree.user_class if to_s == 'Spree::Imports::Customers'
|
|
@@ -21,6 +21,17 @@ module Spree
|
|
|
21
21
|
# in OpenAPI (string-array form was added in typelizer 0.10.0).
|
|
22
22
|
FIELD_TYPE_TOKENS = TYPE_TOKENS.keys.freeze
|
|
23
23
|
|
|
24
|
+
# Whether MetafieldDefinition may enable `searchable` / `sortable` for this
|
|
25
|
+
# STI type. Used only by definition validations (not by SearchProvider
|
|
26
|
+
# indexing). Defaults to false; subclasses override (e.g. ShortText).
|
|
27
|
+
def self.searchable?
|
|
28
|
+
false
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.sortable?
|
|
32
|
+
false
|
|
33
|
+
end
|
|
34
|
+
|
|
24
35
|
has_prefix_id :cf
|
|
25
36
|
|
|
26
37
|
#
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Spree
|
|
4
|
+
class MetafieldDefinition
|
|
5
|
+
module SearchCapabilities
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
attribute :searchable, :boolean, default: false
|
|
10
|
+
attribute :sortable, :boolean, default: false
|
|
11
|
+
|
|
12
|
+
validate :search_sort_capabilities_compatible_with_type
|
|
13
|
+
validate :filter_key_must_be_unique
|
|
14
|
+
|
|
15
|
+
scope :searchable, -> { where(searchable: true) }
|
|
16
|
+
scope :sortable, -> { where(sortable: true) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class_methods do
|
|
20
|
+
# @return [Array<String>] API field_type tokens whose STI class is searchable
|
|
21
|
+
def searchable_field_type_tokens
|
|
22
|
+
@searchable_field_type_tokens ||= build_field_type_tokens(&:searchable?)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [Array<String>] API field_type tokens whose STI class is sortable
|
|
26
|
+
def sortable_field_type_tokens
|
|
27
|
+
@sortable_field_type_tokens ||= build_field_type_tokens(&:sortable?)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# Builds API tokens from {Spree::MetafieldDefinition.available_types}
|
|
33
|
+
# (`Spree.metafields.types` — in-memory registry, not a DB query).
|
|
34
|
+
def build_field_type_tokens(&block)
|
|
35
|
+
available_types.filter_map do |klass|
|
|
36
|
+
next unless block.call(klass)
|
|
37
|
+
|
|
38
|
+
Spree::Metafield::TYPE_CLASS_TO_TOKEN[klass.to_s] || klass.to_s
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Public identifier for sorting and filtering product listings by this
|
|
44
|
+
# custom field (+sort=cf_specs_material+,
|
|
45
|
+
# +q[cf_specs_material_i_cont]=wool+).
|
|
46
|
+
#
|
|
47
|
+
# @return [String] e.g. +cf_custom_label+
|
|
48
|
+
def filter_key
|
|
49
|
+
"cf_#{namespace}_#{key}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
# `key` is already unique per (resource_type, namespace), but both
|
|
55
|
+
# segments normalize to underscored slugs, so two different splits
|
|
56
|
+
# ("a_b"/"c" vs "a"/"b_c") flatten to one filter_key — which would make
|
|
57
|
+
# one of them unaddressable as a sort/filter param.
|
|
58
|
+
def filter_key_must_be_unique
|
|
59
|
+
return if namespace.blank? || key.blank? || resource_type.blank?
|
|
60
|
+
return unless new_record? || namespace_changed? || key_changed? || resource_type_changed?
|
|
61
|
+
|
|
62
|
+
table = self.class.arel_table
|
|
63
|
+
concatenated = Arel::Nodes::NamedFunction.new(
|
|
64
|
+
'CONCAT', [table[:namespace], Arel::Nodes.build_quoted('_'), table[:key]]
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
scope = self.class.
|
|
68
|
+
for_resource_type(resource_type).
|
|
69
|
+
where(concatenated.eq("#{namespace}_#{key}")).
|
|
70
|
+
where(self.class.spree_base_uniqueness_scope.index_with { |attr| public_send(attr) })
|
|
71
|
+
scope = scope.where.not(id: id) if persisted?
|
|
72
|
+
|
|
73
|
+
errors.add(:key, :taken) if scope.exists?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def metafield_type_class
|
|
77
|
+
metafield_type.presence&.safe_constantize
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def search_sort_capabilities_compatible_with_type
|
|
81
|
+
return unless searchable? || sortable?
|
|
82
|
+
|
|
83
|
+
klass = metafield_type_class
|
|
84
|
+
return if klass.nil?
|
|
85
|
+
|
|
86
|
+
%i[searchable sortable].each do |capability|
|
|
87
|
+
next unless public_send("#{capability}?")
|
|
88
|
+
next if klass.public_send("#{capability}?")
|
|
89
|
+
|
|
90
|
+
errors.add(capability, capability_error_message(capability))
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def capability_error_message(capability)
|
|
95
|
+
labels = self.class
|
|
96
|
+
.public_send("#{capability}_field_type_tokens")
|
|
97
|
+
.map { |token| token.tr('_', ' ') }
|
|
98
|
+
.join(', ')
|
|
99
|
+
|
|
100
|
+
"is only supported for field types: #{labels}"
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -3,6 +3,7 @@ module Spree
|
|
|
3
3
|
has_prefix_id :cfdef
|
|
4
4
|
|
|
5
5
|
include Spree::DisplayOn
|
|
6
|
+
include Spree::MetafieldDefinition::SearchCapabilities
|
|
6
7
|
|
|
7
8
|
DISPLAY = [:both, :back_end]
|
|
8
9
|
|
|
@@ -49,7 +50,7 @@ module Spree
|
|
|
49
50
|
#
|
|
50
51
|
# Ransack
|
|
51
52
|
#
|
|
52
|
-
self.whitelisted_ransackable_attributes = %w[key namespace name resource_type display_on]
|
|
53
|
+
self.whitelisted_ransackable_attributes = %w[key namespace name resource_type display_on searchable sortable]
|
|
53
54
|
self.whitelisted_ransackable_scopes = %w[search multi_search]
|
|
54
55
|
|
|
55
56
|
# API naming bridge — internal columns rename in 6.0. `label` matches
|