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
data/app/models/spree/product.rb
CHANGED
|
@@ -31,7 +31,9 @@ module Spree
|
|
|
31
31
|
include Spree::TranslatableResource
|
|
32
32
|
include Spree::MemoizedData
|
|
33
33
|
include Spree::Metafields
|
|
34
|
+
include Spree::MetafieldFilterable
|
|
34
35
|
include Spree::Metadata
|
|
36
|
+
include Spree::Searchable
|
|
35
37
|
include Spree::Product::Webhooks
|
|
36
38
|
include Spree::Product::Slugs
|
|
37
39
|
include Spree::Product::Channels
|
|
@@ -171,7 +173,23 @@ module Spree
|
|
|
171
173
|
next none if query.blank?
|
|
172
174
|
|
|
173
175
|
product_ids = Spree::Variant.search_by_product_name_or_sku(query).pluck(:product_id)
|
|
174
|
-
|
|
176
|
+
|
|
177
|
+
searchable_definitions = Spree::MetafieldDefinition.
|
|
178
|
+
for_resource_type('Spree::Product').
|
|
179
|
+
searchable
|
|
180
|
+
|
|
181
|
+
# Only pay for the metafield scan when some definition opts into search;
|
|
182
|
+
# the leading-wildcard LIKE over spree_metafields can't use an index.
|
|
183
|
+
metafield_ids = if searchable_definitions.exists?
|
|
184
|
+
joins(metafields: :metafield_definition).
|
|
185
|
+
merge(searchable_definitions).
|
|
186
|
+
where(search_condition(Spree::Metafield, :value, query)).
|
|
187
|
+
unscope(:order).distinct.pluck(:id)
|
|
188
|
+
else
|
|
189
|
+
[]
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
where(id: (product_ids + metafield_ids).uniq.compact)
|
|
175
193
|
}
|
|
176
194
|
|
|
177
195
|
# Backward compatibility alias — remove in Spree 6.0
|
data/app/models/spree/report.rb
CHANGED
|
@@ -37,8 +37,18 @@ module Spree
|
|
|
37
37
|
'Spree::Api::V3::ReportSerializer'.safe_constantize
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
# Registered report subclasses, mirroring Spree::Export/Spree::Import so
|
|
41
|
+
# `api_type_for` can resolve an STI column value without constantizing it.
|
|
42
|
+
#
|
|
43
|
+
# @return [Array<Class>]
|
|
44
|
+
def self.available_types
|
|
45
|
+
Spree.reports
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @deprecated Use `.api_type` — identical derivation, consistent with the
|
|
49
|
+
# rest of the API's `type` fields.
|
|
40
50
|
def self.report_type
|
|
41
|
-
|
|
51
|
+
api_type
|
|
42
52
|
end
|
|
43
53
|
|
|
44
54
|
# Returns a scope of records to be used for generating report lines
|
|
@@ -13,6 +13,11 @@ module Spree
|
|
|
13
13
|
@store = store
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# @return [MetafieldSchema]
|
|
17
|
+
def metafield_schema
|
|
18
|
+
@metafield_schema ||= MetafieldSchema.new
|
|
19
|
+
end
|
|
20
|
+
|
|
16
21
|
# Search and paginate products. Does NOT compute filter facets — use #filters for that.
|
|
17
22
|
#
|
|
18
23
|
# @param scope [ActiveRecord::Relation] base scope (store-scoped, visibility-filtered, authorized)
|
|
@@ -46,6 +46,7 @@ module Spree
|
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
filter_facets = build_facets(scope_with_options, category: category, option_value_ids: option_value_ids, scope_before_options: scope_before_options)
|
|
49
|
+
filter_facets[:sort_options] = filter_facets[:sort_options] + metafield_sort_options
|
|
49
50
|
|
|
50
51
|
FiltersResult.new(
|
|
51
52
|
filters: filter_facets[:filters],
|
|
@@ -60,6 +61,9 @@ module Spree
|
|
|
60
61
|
def apply_search_and_filters(scope, query: nil, filters: {})
|
|
61
62
|
scope = scope.search(query) if query.present?
|
|
62
63
|
|
|
64
|
+
filters = filters.to_unsafe_h if filters.respond_to?(:to_unsafe_h)
|
|
65
|
+
scope, filters = scope.with_metafield_filters(filters, schema: metafield_schema)
|
|
66
|
+
|
|
63
67
|
ransack_filters = sanitize_filters(filters)
|
|
64
68
|
if ransack_filters.present?
|
|
65
69
|
search = scope.ransack(ransack_filters)
|
|
@@ -74,7 +78,7 @@ module Spree
|
|
|
74
78
|
end
|
|
75
79
|
|
|
76
80
|
def build_facets(scope, category: nil, option_value_ids: [], scope_before_options: nil)
|
|
77
|
-
return { filters: [], sort_options:
|
|
81
|
+
return { filters: [], sort_options: built_in_sort_options, default_sort: 'manual', total_count: scope.distinct.count } unless defined?(Spree::Api::V3::FiltersAggregator)
|
|
78
82
|
|
|
79
83
|
Spree::Api::V3::FiltersAggregator.new(
|
|
80
84
|
scope: scope,
|
|
@@ -98,6 +102,8 @@ module Spree
|
|
|
98
102
|
scope_method = CUSTOM_SORT_SCOPES[sort]
|
|
99
103
|
if scope_method
|
|
100
104
|
scope.reorder(nil).send(scope_method)
|
|
105
|
+
elsif (parsed = metafield_schema.parse_sort(sort))
|
|
106
|
+
apply_metafield_sort(scope, parsed)
|
|
101
107
|
else
|
|
102
108
|
# Standard Ransack sort: 'name' → 'name asc', '-name' → 'name desc'
|
|
103
109
|
ransack_sort = sort.split(',').map { |field|
|
|
@@ -112,8 +118,48 @@ module Spree
|
|
|
112
118
|
end
|
|
113
119
|
end
|
|
114
120
|
|
|
115
|
-
|
|
116
|
-
|
|
121
|
+
# Project the ORDER BY expression into the SELECT list so PostgreSQL
|
|
122
|
+
# accepts it when DISTINCT is applied. Null-rank keeps products without
|
|
123
|
+
# the metafield last for both ASC and DESC.
|
|
124
|
+
def apply_metafield_sort(scope, parsed)
|
|
125
|
+
definition = metafield_schema.entry_for(parsed[:attribute])
|
|
126
|
+
return scope unless definition
|
|
127
|
+
|
|
128
|
+
products = Spree::Product.arel_table
|
|
129
|
+
# Aliased so this join can't collide with the `filter_cf` correlated
|
|
130
|
+
# subquery when a cf_* filter and a cf_* sort are combined.
|
|
131
|
+
metafields = Spree::Metafield.arel_table.alias('sort_cf')
|
|
132
|
+
|
|
133
|
+
sort_expr = Spree::Product.metafield_value_expression(metafields[:value], definition.field_type)
|
|
134
|
+
# NULL-rank so products missing the metafield sort last (Meilisearch parity).
|
|
135
|
+
# Projected as cf_sort_missing because PostgreSQL rejects (alias IS NULL) in ORDER BY.
|
|
136
|
+
null_rank = sort_expr.eq(nil)
|
|
137
|
+
|
|
138
|
+
direction = parsed[:direction] == 'desc' ? :desc : :asc
|
|
139
|
+
|
|
140
|
+
join = products.
|
|
141
|
+
outer_join(metafields).
|
|
142
|
+
on(
|
|
143
|
+
metafields[:resource_type].eq('Spree::Product').
|
|
144
|
+
and(metafields[:resource_id].eq(products[:id])).
|
|
145
|
+
and(metafields[:metafield_definition_id].eq(definition.id))
|
|
146
|
+
).
|
|
147
|
+
join_sources
|
|
148
|
+
|
|
149
|
+
scope.
|
|
150
|
+
joins(join).
|
|
151
|
+
select(products[Arel.star]).
|
|
152
|
+
select(Arel::Nodes::As.new(sort_expr, Arel.sql('cf_sort_value'))).
|
|
153
|
+
select(Arel::Nodes::As.new(null_rank, Arel.sql('cf_sort_missing'))).
|
|
154
|
+
reorder(Arel.sql('cf_sort_missing').asc, sort_expr.public_send(direction))
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def built_in_sort_options
|
|
158
|
+
%w[price -price best_selling name -name -available_on available_on].map { |id| { id: id, label: nil } }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def metafield_sort_options
|
|
162
|
+
metafield_schema.sort_options
|
|
117
163
|
end
|
|
118
164
|
end
|
|
119
165
|
end
|
|
@@ -5,6 +5,8 @@ module Spree
|
|
|
5
5
|
class Meilisearch < Base
|
|
6
6
|
PREFIXED_ID_PATTERN = /\A[a-z]+_[A-Za-z0-9]+\z/
|
|
7
7
|
ALLOWED_STATUSES = %w[active draft archived paused].freeze
|
|
8
|
+
BUILT_IN_FILTERABLE_ATTRIBUTES = %w[product_id status in_stock preorder store_ids channel_ids locale currency available_on discontinue_on price category_ids tags option_value_ids].freeze
|
|
9
|
+
METAFIELD_RANGE_OPERATORS = { 'gt' => '>', 'gteq' => '>=', 'lt' => '<', 'lteq' => '<=' }.freeze
|
|
8
10
|
|
|
9
11
|
def self.indexing_required?
|
|
10
12
|
true
|
|
@@ -51,7 +53,7 @@ module Spree
|
|
|
51
53
|
unless ms_result
|
|
52
54
|
return FiltersResult.new(
|
|
53
55
|
filters: [],
|
|
54
|
-
sort_options:
|
|
56
|
+
sort_options: built_in_and_metafield_sort_options,
|
|
55
57
|
default_sort: 'manual',
|
|
56
58
|
total_count: 0
|
|
57
59
|
)
|
|
@@ -59,7 +61,7 @@ module Spree
|
|
|
59
61
|
|
|
60
62
|
FiltersResult.new(
|
|
61
63
|
filters: build_facet_response(facet_distribution),
|
|
62
|
-
sort_options:
|
|
64
|
+
sort_options: built_in_and_metafield_sort_options,
|
|
63
65
|
default_sort: 'manual',
|
|
64
66
|
total_count: ms_result['estimatedTotalHits'] || 0
|
|
65
67
|
)
|
|
@@ -91,6 +93,7 @@ module Spree
|
|
|
91
93
|
|
|
92
94
|
def reindex(scope = nil)
|
|
93
95
|
scope ||= store.products
|
|
96
|
+
@metafield_schema = MetafieldSchema.new
|
|
94
97
|
ensure_index_settings!
|
|
95
98
|
|
|
96
99
|
indexed = 0
|
|
@@ -180,7 +183,7 @@ module Spree
|
|
|
180
183
|
# is referenced before the index settings were refreshed — otherwise
|
|
181
184
|
# every search on an upgraded store returns empty until a reindex or
|
|
182
185
|
# the next product write.
|
|
183
|
-
if e.code
|
|
186
|
+
if %w[invalid_search_filter invalid_search_sort].include?(e.code) && !settings_refreshed
|
|
184
187
|
settings_refreshed = true
|
|
185
188
|
ensure_index_settings!
|
|
186
189
|
retry
|
|
@@ -240,23 +243,30 @@ module Spree
|
|
|
240
243
|
end
|
|
241
244
|
|
|
242
245
|
def searchable_attributes
|
|
243
|
-
%w[name description sku option_values category_names tags]
|
|
246
|
+
%w[name description sku option_values category_names tags] + metafield_schema.searchable_attribute_keys
|
|
244
247
|
end
|
|
245
248
|
|
|
246
249
|
def filterable_attributes
|
|
247
|
-
|
|
250
|
+
BUILT_IN_FILTERABLE_ATTRIBUTES + metafield_schema.filterable_attribute_keys
|
|
248
251
|
end
|
|
249
252
|
|
|
250
253
|
def sortable_attributes
|
|
251
|
-
%w[name price created_at available_on units_sold_count]
|
|
254
|
+
%w[name price created_at available_on units_sold_count] + metafield_schema.sortable_attribute_keys
|
|
252
255
|
end
|
|
253
256
|
|
|
257
|
+
# Facets stay built-in only — cf_* distributions aren't consumed by
|
|
258
|
+
# +build_facet_response+, so requesting them would be wasted work.
|
|
254
259
|
def facet_attributes
|
|
255
|
-
|
|
260
|
+
BUILT_IN_FILTERABLE_ATTRIBUTES
|
|
256
261
|
end
|
|
257
262
|
|
|
258
263
|
def available_sort_options
|
|
259
|
-
%w[price -price name -name -available_on available_on best_selling]
|
|
264
|
+
%w[price -price name -name -available_on available_on best_selling] + metafield_schema.sort_ids
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def built_in_and_metafield_sort_options
|
|
268
|
+
%w[price -price name -name -available_on available_on best_selling].map { |id| { id: id, label: nil } } +
|
|
269
|
+
metafield_schema.sort_options
|
|
260
270
|
end
|
|
261
271
|
|
|
262
272
|
# Build Meilisearch filter conditions from API params.
|
|
@@ -331,9 +341,51 @@ module Spree
|
|
|
331
341
|
when 'with_option_value_ids'
|
|
332
342
|
# Handled by grouped option conditions in search_and_filter — skip here
|
|
333
343
|
nil
|
|
344
|
+
else
|
|
345
|
+
metafield_filter_condition(key, value)
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Meilisearch has stable filter operators for equality, ranges, and
|
|
350
|
+
# EXISTS — but no string contains/starts/ends (CONTAINS is still
|
|
351
|
+
# experimental), so those predicates are ignored here. The Database
|
|
352
|
+
# provider supports the full predicate set.
|
|
353
|
+
def metafield_filter_condition(key, value)
|
|
354
|
+
parsed = metafield_schema.parse_filter(key)
|
|
355
|
+
return nil unless parsed
|
|
356
|
+
|
|
357
|
+
attribute = parsed[:definition].filter_key
|
|
358
|
+
numeric = parsed[:definition].field_type == 'number'
|
|
359
|
+
|
|
360
|
+
case parsed[:predicate]
|
|
361
|
+
when 'present'
|
|
362
|
+
"#{attribute} EXISTS"
|
|
363
|
+
when 'blank'
|
|
364
|
+
"#{attribute} NOT EXISTS"
|
|
365
|
+
when 'eq', 'not_eq'
|
|
366
|
+
operator = parsed[:predicate] == 'eq' ? '=' : '!='
|
|
367
|
+
formatted = format_metafield_filter_value(value, numeric)
|
|
368
|
+
formatted && "#{attribute} #{operator} #{formatted}"
|
|
369
|
+
when *METAFIELD_RANGE_OPERATORS.keys
|
|
370
|
+
return nil unless numeric
|
|
371
|
+
|
|
372
|
+
formatted = format_metafield_filter_value(value, numeric)
|
|
373
|
+
formatted && "#{attribute} #{METAFIELD_RANGE_OPERATORS[parsed[:predicate]]} #{formatted}"
|
|
334
374
|
end
|
|
335
375
|
end
|
|
336
376
|
|
|
377
|
+
def format_metafield_filter_value(value, numeric)
|
|
378
|
+
if numeric
|
|
379
|
+
Float(value.to_s, exception: false)&.to_s
|
|
380
|
+
else
|
|
381
|
+
"'#{escape_filter_string(value)}'"
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def escape_filter_string(value)
|
|
386
|
+
value.to_s.gsub('\\') { '\\\\' }.gsub("'") { "\\'" }
|
|
387
|
+
end
|
|
388
|
+
|
|
337
389
|
# Group prefixed option value IDs by option type (single DB query).
|
|
338
390
|
# Returns { option_type_id => ['optval_abc', 'optval_def'], ... }
|
|
339
391
|
def group_option_values_by_type(prefixed_ids)
|
|
@@ -380,6 +432,9 @@ module Spree
|
|
|
380
432
|
when '-available_on' then ['available_on:desc']
|
|
381
433
|
when 'available_on' then ['available_on:asc']
|
|
382
434
|
when 'best_selling' then ['units_sold_count:desc']
|
|
435
|
+
else
|
|
436
|
+
parsed = metafield_schema.parse_sort(sort)
|
|
437
|
+
parsed && ["#{parsed[:attribute]}:#{parsed[:direction]}"]
|
|
383
438
|
end
|
|
384
439
|
end
|
|
385
440
|
|
|
@@ -75,7 +75,27 @@ module Spree
|
|
|
75
75
|
available_on: product.available_on&.iso8601,
|
|
76
76
|
created_at: product.created_at&.iso8601,
|
|
77
77
|
updated_at: product.updated_at&.iso8601
|
|
78
|
-
}
|
|
78
|
+
}.merge(metafield_document_attributes)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Flat cf_* hash for searchable ∪ sortable metafields (Meilisearch docs).
|
|
82
|
+
def metafield_document_attributes
|
|
83
|
+
@metafield_document_attributes ||= product.metafields.filter_map do |metafield|
|
|
84
|
+
definition = metafield.metafield_definition
|
|
85
|
+
next unless definition&.searchable? || definition&.sortable?
|
|
86
|
+
|
|
87
|
+
[definition.filter_key, metafield_index_value(metafield, definition.field_type)]
|
|
88
|
+
end.to_h
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def metafield_index_value(metafield, field_type)
|
|
92
|
+
serialized = metafield.serialize_value
|
|
93
|
+
case field_type
|
|
94
|
+
when 'number'
|
|
95
|
+
serialized.to_f
|
|
96
|
+
else
|
|
97
|
+
serialized.to_s
|
|
98
|
+
end
|
|
79
99
|
end
|
|
80
100
|
|
|
81
101
|
# Returns all market × locale pairs for this store
|
|
@@ -295,11 +295,16 @@ module Spree
|
|
|
295
295
|
end
|
|
296
296
|
|
|
297
297
|
def handle_tags(product)
|
|
298
|
+
return Spree::Imports::AssignTagsJob.perform_now(product.id, attributes['tags']) if import.preferred_inline
|
|
299
|
+
|
|
298
300
|
Spree::Imports::AssignTagsJob.perform_later(product.id, attributes['tags'])
|
|
299
301
|
end
|
|
300
302
|
|
|
301
303
|
def handle_categories(product)
|
|
302
|
-
|
|
304
|
+
names = prepare_taxon_pretty_names
|
|
305
|
+
return Spree::Imports::CreateCategoriesJob.perform_now(product.id, store.id, names) if import.preferred_inline
|
|
306
|
+
|
|
307
|
+
Spree::Imports::CreateCategoriesJob.perform_later(product.id, store.id, names)
|
|
303
308
|
end
|
|
304
309
|
|
|
305
310
|
def prepare_taxon_pretty_names
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module SampleData
|
|
3
|
+
# Shared by the sample-data drivers: `Spree::SampleData::Loader` (the
|
|
4
|
+
# synchronous, rake-facing one) and any app that loads a subset itself —
|
|
5
|
+
# e.g. running the configuration files inline and handing the CSVs to the
|
|
6
|
+
# import pipeline. Keeping the file locations and catalog-publishing rules
|
|
7
|
+
# here means they don't have to be kept in sync by hand.
|
|
8
|
+
module Helper
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def sample_data_path
|
|
12
|
+
@sample_data_path ||= Spree::Core::Engine.root.join('db', 'sample_data')
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def load_ruby_file(name)
|
|
16
|
+
file = sample_data_path.join("#{name}.rb")
|
|
17
|
+
load file.to_s if file.exist?
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Publishes the catalog to every channel the store has. The import only
|
|
21
|
+
# adds a product to the default channel, and only when it has none, so
|
|
22
|
+
# anything else the store defines (wholesale, a locale-specific storefront,
|
|
23
|
+
# whatever an app has seeded) would otherwise be left empty. Catalog parity
|
|
24
|
+
# is the point — channels differentiate through their own gates and price
|
|
25
|
+
# lists, not through a narrower catalog. `add_products` upserts in bulk and
|
|
26
|
+
# skips rows that already exist.
|
|
27
|
+
def publish_sample_products(store)
|
|
28
|
+
product_ids = store.product_ids
|
|
29
|
+
return if product_ids.empty?
|
|
30
|
+
|
|
31
|
+
store.channels.each { |channel| channel.add_products(product_ids) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Cheap proxy for "the base seeds have run": the US states are seeded
|
|
35
|
+
# alongside everything else, and products/addresses depend on them.
|
|
36
|
+
def seeds_loaded?
|
|
37
|
+
Spree::State.joins(:country).where(spree_countries: { iso: 'US' }).exists?
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def ensure_seeds_loaded
|
|
41
|
+
return if seeds_loaded?
|
|
42
|
+
|
|
43
|
+
Spree::Seeds::All.call
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def without_geocoding
|
|
47
|
+
previous = Spree::Config[:geocode_addresses]
|
|
48
|
+
Spree::Config[:geocode_addresses] = false
|
|
49
|
+
yield
|
|
50
|
+
ensure
|
|
51
|
+
Spree::Config[:geocode_addresses] = previous
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module SampleData
|
|
3
|
+
# Creates the `Spree::Import` record for a sample CSV, attaching the file
|
|
4
|
+
# but processing nothing — the two runners differ only in what they do next.
|
|
5
|
+
class ImportBuilder
|
|
6
|
+
# @return [Spree::Import] persisted, unprocessed
|
|
7
|
+
def self.call(csv_path:, import_class:, store: nil, user: nil, inline: false, skip_events: false)
|
|
8
|
+
store ||= Spree::Store.default
|
|
9
|
+
# An admin of the target store, not `admin_user_class.first` — in a
|
|
10
|
+
# multi-store or multi-tenant app that global first can belong to a
|
|
11
|
+
# different store entirely.
|
|
12
|
+
user ||= store.users.first
|
|
13
|
+
|
|
14
|
+
raise 'No admin user found. Please run seeds first.' unless user
|
|
15
|
+
|
|
16
|
+
import = import_class.new(owner: store, user: user)
|
|
17
|
+
# Persisted before save: the processing jobs reload the record, so these
|
|
18
|
+
# have to travel with it rather than live on the instance.
|
|
19
|
+
import.preferred_inline = inline
|
|
20
|
+
import.preferred_skip_events = skip_events
|
|
21
|
+
import.number = import.generate_permalink(import_class)
|
|
22
|
+
import.attachment.attach(
|
|
23
|
+
io: File.open(csv_path),
|
|
24
|
+
filename: File.basename(csv_path),
|
|
25
|
+
content_type: 'text/csv'
|
|
26
|
+
)
|
|
27
|
+
import.save!(validate: false)
|
|
28
|
+
import
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -1,53 +1,34 @@
|
|
|
1
|
-
require 'csv'
|
|
2
|
-
|
|
3
1
|
module Spree
|
|
4
2
|
module SampleData
|
|
3
|
+
# Runs a sample CSV through the regular import pipeline: `start_mapping`
|
|
4
|
+
# auto-assigns the column mappings from the CSV headers (the sample files
|
|
5
|
+
# use the canonical schema names, so every column maps) and
|
|
6
|
+
# `complete_mapping` kicks off row creation and processing.
|
|
7
|
+
#
|
|
8
|
+
# Enqueues by default, so row creation, batching and the per-import
|
|
9
|
+
# concurrency cap behave exactly as they do for a merchant-uploaded CSV.
|
|
10
|
+
# Pass `inline: true` for rake tasks, seeds and the console — where the data
|
|
11
|
+
# has to exist when the call returns and there may be no worker attached.
|
|
12
|
+
# Never inline from a request: `products.csv` alone is 211 rows, each
|
|
13
|
+
# downloading a remote image.
|
|
5
14
|
class ImportRunner
|
|
6
15
|
prepend Spree::ServiceModule::Base
|
|
7
16
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
import =
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
)
|
|
18
|
-
import.number = import.generate_permalink(import_class)
|
|
19
|
-
import.attachment.attach(
|
|
20
|
-
io: File.open(csv_path),
|
|
21
|
-
filename: File.basename(csv_path),
|
|
22
|
-
content_type: 'text/csv'
|
|
17
|
+
# Pass `skip_events: true` when seeding demo data — subscribers would
|
|
18
|
+
# otherwise deliver webhooks and analytics for a catalog nobody created.
|
|
19
|
+
#
|
|
20
|
+
# @return [Spree::ServiceModule::Result] wrapping the import — completed
|
|
21
|
+
# when inline, queued otherwise
|
|
22
|
+
def call(csv_path:, import_class:, store: nil, user: nil, inline: false, skip_events: false)
|
|
23
|
+
import = Spree::SampleData::ImportBuilder.call(
|
|
24
|
+
csv_path: csv_path, import_class: import_class, store: store, user: user,
|
|
25
|
+
inline: inline, skip_events: skip_events
|
|
23
26
|
)
|
|
24
|
-
import.save!(validate: false)
|
|
25
|
-
import.update_columns(status: 'processing')
|
|
26
|
-
import.create_mappings
|
|
27
|
-
|
|
28
|
-
row_number = 0
|
|
29
|
-
failed = 0
|
|
30
|
-
|
|
31
|
-
::CSV.foreach(csv_path, headers: true) do |csv_row|
|
|
32
|
-
row_number += 1
|
|
33
|
-
import_row = import.rows.create!(
|
|
34
|
-
row_number: row_number,
|
|
35
|
-
data: csv_row.to_h.to_json,
|
|
36
|
-
status: 'pending'
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
begin
|
|
40
|
-
import_row.process!
|
|
41
|
-
rescue StandardError => e
|
|
42
|
-
failed += 1
|
|
43
|
-
puts "\n Warning: Row #{row_number} failed: #{e.message}"
|
|
44
|
-
end
|
|
45
27
|
|
|
46
|
-
|
|
47
|
-
|
|
28
|
+
import.start_mapping
|
|
29
|
+
import.complete_mapping
|
|
48
30
|
|
|
49
|
-
import.
|
|
50
|
-
puts "\n Processed #{row_number} rows (#{failed} failed)"
|
|
31
|
+
success(import.reload)
|
|
51
32
|
end
|
|
52
33
|
end
|
|
53
34
|
end
|
|
@@ -2,11 +2,13 @@ module Spree
|
|
|
2
2
|
module SampleData
|
|
3
3
|
class Loader
|
|
4
4
|
prepend Spree::ServiceModule::Base
|
|
5
|
+
include Spree::SampleData::Helper
|
|
5
6
|
|
|
6
7
|
def call
|
|
7
8
|
Spree::Events.disable do
|
|
8
9
|
without_geocoding do
|
|
9
10
|
ActiveRecord::Base.no_touching do
|
|
11
|
+
puts 'Running seeds first...' unless seeds_loaded?
|
|
10
12
|
ensure_seeds_loaded
|
|
11
13
|
|
|
12
14
|
puts 'Loading sample configuration data...'
|
|
@@ -28,10 +30,7 @@ module Spree
|
|
|
28
30
|
load_products
|
|
29
31
|
|
|
30
32
|
puts 'Publishing sample products on the default channel...'
|
|
31
|
-
publish_sample_products
|
|
32
|
-
|
|
33
|
-
puts 'Loading sample categories...'
|
|
34
|
-
load_categories
|
|
33
|
+
publish_sample_products(Spree::Store.default)
|
|
35
34
|
|
|
36
35
|
puts 'Loading sample product translations...'
|
|
37
36
|
load_product_translations
|
|
@@ -56,18 +55,6 @@ module Spree
|
|
|
56
55
|
|
|
57
56
|
private
|
|
58
57
|
|
|
59
|
-
def ensure_seeds_loaded
|
|
60
|
-
us = Spree::Country.find_by(iso: 'US')
|
|
61
|
-
return if us&.states&.any? && Spree::Store.default&.persisted?
|
|
62
|
-
|
|
63
|
-
puts 'Running seeds first...'
|
|
64
|
-
Spree::Seeds::All.call
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def sample_data_path
|
|
68
|
-
@sample_data_path ||= Spree::Core::Engine.root.join('db', 'sample_data')
|
|
69
|
-
end
|
|
70
|
-
|
|
71
58
|
def load_configuration_data
|
|
72
59
|
load_ruby_file('shipping_methods')
|
|
73
60
|
load_ruby_file('payment_methods')
|
|
@@ -76,57 +63,19 @@ module Spree
|
|
|
76
63
|
|
|
77
64
|
def load_products
|
|
78
65
|
csv_path = sample_data_path.join('products.csv')
|
|
79
|
-
Spree::SampleData::ImportRunner.call(csv_path: csv_path, import_class: Spree::Imports::Products)
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
# Publishes the catalog to both surfaces: the public default channel and
|
|
83
|
-
# the gated wholesale channel (catalog parity; wholesale differentiation
|
|
84
|
-
# comes from the price list in +wholesale.rb+ and the channel's gate).
|
|
85
|
-
def publish_sample_products
|
|
86
|
-
store = Spree::Store.default
|
|
87
|
-
store.default_channel.add_products(store.product_ids)
|
|
88
|
-
store.channels.find_by(code: Spree::Seeds::Channels::WHOLESALE_CODE)&.add_products(store.product_ids)
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
def load_categories
|
|
92
|
-
store = Spree::Store.default
|
|
93
|
-
csv_path = sample_data_path.join('products.csv')
|
|
94
|
-
|
|
95
|
-
require 'csv'
|
|
96
|
-
::CSV.foreach(csv_path, headers: true) do |row|
|
|
97
|
-
product = store.products.find_by(slug: row['slug'])
|
|
98
|
-
next unless product
|
|
99
|
-
|
|
100
|
-
categories = [row['category1'], row['category2'], row['category3']].compact_blank
|
|
101
|
-
next if categories.empty?
|
|
102
|
-
|
|
103
|
-
Spree::Imports::CreateCategoriesJob.perform_now(product.id, store.id, categories)
|
|
104
|
-
end
|
|
66
|
+
Spree::SampleData::ImportRunner.call(csv_path: csv_path, import_class: Spree::Imports::Products, inline: true)
|
|
105
67
|
end
|
|
106
68
|
|
|
107
69
|
def load_product_translations
|
|
108
70
|
csv_path = sample_data_path.join('product_translations.csv')
|
|
109
71
|
return unless csv_path.exist?
|
|
110
72
|
|
|
111
|
-
Spree::SampleData::ImportRunner.call(csv_path: csv_path, import_class: Spree::Imports::ProductTranslations)
|
|
73
|
+
Spree::SampleData::ImportRunner.call(csv_path: csv_path, import_class: Spree::Imports::ProductTranslations, inline: true)
|
|
112
74
|
end
|
|
113
75
|
|
|
114
76
|
def load_customers
|
|
115
77
|
csv_path = sample_data_path.join('customers.csv')
|
|
116
|
-
Spree::SampleData::ImportRunner.call(csv_path: csv_path, import_class: Spree::Imports::Customers)
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
def load_ruby_file(name)
|
|
120
|
-
file = sample_data_path.join("#{name}.rb")
|
|
121
|
-
load file.to_s if file.exist?
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
def without_geocoding
|
|
125
|
-
previous = Spree::Config[:geocode_addresses]
|
|
126
|
-
Spree::Config[:geocode_addresses] = false
|
|
127
|
-
yield
|
|
128
|
-
ensure
|
|
129
|
-
Spree::Config[:geocode_addresses] = previous
|
|
78
|
+
Spree::SampleData::ImportRunner.call(csv_path: csv_path, import_class: Spree::Imports::Customers, inline: true)
|
|
130
79
|
end
|
|
131
80
|
end
|
|
132
81
|
end
|