spree_core 6.0.0.rc5 → 6.0.0.rc7
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/images/save_from_url_job.rb +24 -5
- data/app/models/concerns/spree/account_deactivation.rb +54 -0
- data/app/models/concerns/spree/calculator/currency_amounts.rb +19 -6
- data/app/models/concerns/spree/derived_number.rb +1 -1
- data/app/models/concerns/spree/has_external_references.rb +24 -16
- data/app/models/concerns/spree/prefixed_id.rb +15 -5
- data/app/models/concerns/spree/publishable.rb +47 -1
- data/app/models/concerns/spree/purchase/addresses.rb +19 -4
- data/app/models/concerns/spree/purchase/digital_items.rb +17 -3
- data/app/models/concerns/spree/purchase/freight.rb +2 -1
- data/app/models/concerns/spree/purchase/payment_processing.rb +7 -1
- data/app/models/concerns/spree/purchase/quantity_rules.rb +2 -0
- data/app/models/concerns/spree/stores/channels.rb +3 -1
- data/app/models/concerns/spree/translatable_resource.rb +6 -1
- data/app/models/spree/admin_user.rb +1 -0
- data/app/models/spree/base.rb +33 -1
- data/app/models/spree/calculator/shipping/digital_delivery.rb +3 -1
- data/app/models/spree/calculator/shipping/flat_rate.rb +3 -1
- data/app/models/spree/calculator/shipping/per_item.rb +3 -1
- data/app/models/spree/cart.rb +4 -1
- data/app/models/spree/category.rb +76 -5
- data/app/models/spree/channel.rb +79 -40
- data/app/models/spree/company.rb +45 -2
- data/app/models/spree/customer.rb +1 -1
- data/app/models/spree/customer_group.rb +59 -41
- data/app/models/spree/delivery_method.rb +18 -8
- data/app/models/spree/delivery_profile.rb +18 -0
- data/app/models/spree/digital_asset.rb +14 -0
- data/app/models/spree/event.rb +20 -5
- data/app/models/spree/fulfillment.rb +8 -1
- data/app/models/spree/fulfillment_changer.rb +3 -1
- data/app/models/spree/fulfillment_item.rb +11 -0
- data/app/models/spree/import.rb +20 -2
- data/app/models/spree/order_routing/strategy/rules.rb +4 -1
- data/app/models/spree/price_list.rb +15 -0
- data/app/models/spree/product/channels.rb +16 -8
- data/app/models/spree/product.rb +7 -2
- data/app/models/spree/return.rb +8 -1
- data/app/models/spree/search_provider/base.rb +8 -3
- data/app/models/spree/search_provider/database.rb +2 -10
- data/app/models/spree/seller.rb +35 -5
- data/app/models/spree/seller_balance.rb +28 -0
- data/app/models/spree/seller_requirements/delivery_method.rb +20 -2
- data/app/models/spree/stock/coordinator.rb +7 -4
- data/app/models/spree/stock/estimator.rb +6 -0
- data/app/models/spree/stock/inventory_unit_builder.rb +14 -2
- data/app/models/spree/stock/packer.rb +39 -5
- data/app/models/spree/stock/quantifier.rb +16 -0
- data/app/models/spree/stock_location.rb +15 -8
- data/app/models/spree/store.rb +31 -2
- data/app/models/spree/store_credit.rb +4 -4
- data/app/models/spree/variant.rb +23 -6
- data/app/models/spree/webhook_endpoint.rb +45 -11
- data/app/services/spree/carts/price_items.rb +3 -0
- data/app/services/spree/carts/split_by_seller.rb +3 -1
- data/app/services/spree/catalogs/set_product_terms.rb +8 -3
- data/app/services/spree/collections/add_products.rb +1 -1
- data/app/services/spree/collections/regenerate_products.rb +1 -3
- data/app/services/spree/collections/remove_products.rb +1 -1
- data/app/services/spree/newsletter/subscribe.rb +9 -1
- data/app/services/spree/order_groups/allocate_payments.rb +7 -5
- data/app/services/spree/orders/update.rb +19 -0
- data/app/services/spree/product_memberships.rb +16 -6
- data/app/services/spree/products/duplicator.rb +12 -4
- data/app/services/spree/search_provider/custom_field_schema.rb +10 -2
- data/app/services/spree/stock_levels/correct.rb +11 -4
- data/app/services/spree/stock_reservations/reserve.rb +1 -1
- data/app/services/spree/stores/provision_defaults.rb +11 -7
- data/app/services/spree/tags/bulk_add.rb +16 -6
- data/app/workflows/spree/carts/complete.rb +25 -9
- data/app/workflows/spree/carts/upsert_items.rb +31 -4
- data/app/workflows/spree/customers/anonymize.rb +2 -2
- data/app/workflows/spree/digital_assets/create.rb +56 -0
- data/app/workflows/spree/digital_assets/update.rb +44 -0
- data/app/workflows/spree/digital_assets/writing.rb +108 -0
- data/app/workflows/spree/imports/start_mapping.rb +3 -1
- data/app/workflows/spree/orders/complete.rb +12 -5
- data/app/workflows/spree/payments/capture.rb +12 -0
- data/app/workflows/spree/products/nested_attributes.rb +12 -28
- data/app/workflows/spree/products/propose.rb +9 -4
- data/app/workflows/spree/stock_levels/bulk_upsert.rb +28 -13
- data/app/workflows/spree/variants/nested_attributes.rb +35 -10
- data/config/locales/en.yml +10 -0
- data/db/migrate/20261022000001_store_credit_actor_polymorphic.rb +13 -0
- data/db/migrate/20261023100001_add_deactivated_at_to_spree_admin_users.rb +7 -0
- data/lib/generators/spree/dummy/templates/rails/test.rb +87 -0
- data/lib/spree/checkout/default_requirements.rb +12 -2
- data/lib/spree/core/dependencies.rb +5 -0
- data/lib/spree/core/permissions/default_catalog.rb +7 -2
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/reporting/default_vocabulary.rb +1 -1
- data/lib/spree/testing_support/factories/product_factory.rb +10 -4
- data/lib/spree/testing_support/factories/store_factory.rb +4 -1
- data/lib/spree/upgrades/5_6_to_6_0/manifest.yml +10 -0
- data/lib/tasks/backfill_tagging_tenants.rake +35 -0
- data/lib/tasks/seller_sample_data.rake +114 -112
- metadata +13 -6
|
@@ -81,7 +81,13 @@ module Spree
|
|
|
81
81
|
validates :meta_description
|
|
82
82
|
validates :meta_title
|
|
83
83
|
end
|
|
84
|
-
|
|
84
|
+
# Only a file assigned in this write is checked: a stored one passed when
|
|
85
|
+
# it was attached, and reading it back would cost a query per slot on
|
|
86
|
+
# every save — including each category a product write validates.
|
|
87
|
+
validates :image, content_type: Rails.application.config.active_storage.web_image_content_types,
|
|
88
|
+
if: -> { attachment_changes.key?('image') }
|
|
89
|
+
validates :square_image, content_type: Rails.application.config.active_storage.web_image_content_types,
|
|
90
|
+
if: -> { attachment_changes.key?('square_image') }
|
|
85
91
|
|
|
86
92
|
#
|
|
87
93
|
# Callbacks
|
|
@@ -183,16 +189,81 @@ module Spree
|
|
|
183
189
|
def self.recalculate_products_count(category_ids)
|
|
184
190
|
changed = unscoped.where(id: Array(category_ids).compact.uniq)
|
|
185
191
|
|
|
186
|
-
# The affected nodes are each changed category plus its ancestors
|
|
187
|
-
|
|
192
|
+
# The affected nodes are each changed category plus its ancestors,
|
|
193
|
+
# collected in one read: an ancestor chain per category is a query per
|
|
194
|
+
# node, which a bulk catalog move pays for every category it touched.
|
|
195
|
+
# A nested set makes an ancestor a node whose `lft`/`rgt` span the
|
|
196
|
+
# child's, so one range query answers for all of them.
|
|
197
|
+
changed = changed.to_a
|
|
198
|
+
return if changed.empty?
|
|
199
|
+
|
|
200
|
+
ancestor_conditions = changed.map do |category|
|
|
201
|
+
arel_table[:store_id].eq(category.store_id).
|
|
202
|
+
and(arel_table[:lft].lteq(category.lft)).
|
|
203
|
+
and(arel_table[:rgt].gteq(category.rgt))
|
|
204
|
+
end.reduce(:or)
|
|
205
|
+
|
|
206
|
+
affected = unscoped.where(ancestor_conditions).to_a
|
|
207
|
+
|
|
208
|
+
# One grouped query for the whole set rather than a count per node:
|
|
209
|
+
# this runs on every catalog move, and a deep tree would otherwise
|
|
210
|
+
# spend a query on each affected ancestor.
|
|
211
|
+
counts = distinct_product_counts_for(affected)
|
|
188
212
|
|
|
189
213
|
affected.each do |category|
|
|
190
|
-
count =
|
|
191
|
-
.distinct.count(:product_id)
|
|
214
|
+
count = counts.fetch(category.id, 0)
|
|
192
215
|
category.update_column(:products_count, count) if category.products_count != count
|
|
193
216
|
end
|
|
194
217
|
end
|
|
195
218
|
|
|
219
|
+
# Distinct product counts per category, each counting its whole subtree.
|
|
220
|
+
# A nested set stores the subtree as an `lft`/`rgt` range, so the tie
|
|
221
|
+
# between a node and its descendants is a range join rather than a
|
|
222
|
+
# separate descendant query for every node.
|
|
223
|
+
#
|
|
224
|
+
# @param categories [Array<Spree::Category>]
|
|
225
|
+
# @return [Hash{Integer => Integer}] category id => distinct product count
|
|
226
|
+
def self.distinct_product_counts_for(categories)
|
|
227
|
+
return {} if categories.empty?
|
|
228
|
+
|
|
229
|
+
nodes = arel_table.alias('counted_nodes')
|
|
230
|
+
descendants = arel_table
|
|
231
|
+
product_categories = Spree::ProductCategory.arel_table
|
|
232
|
+
|
|
233
|
+
relation = unscoped.
|
|
234
|
+
from(nodes).
|
|
235
|
+
joins(
|
|
236
|
+
Arel::Nodes::InnerJoin.new(
|
|
237
|
+
descendants,
|
|
238
|
+
Arel::Nodes::On.new(
|
|
239
|
+
descendants[:store_id].eq(nodes[:store_id]).
|
|
240
|
+
and(descendants[:lft].gteq(nodes[:lft])).
|
|
241
|
+
and(descendants[:rgt].lteq(nodes[:rgt]))
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
).
|
|
245
|
+
joins(
|
|
246
|
+
Arel::Nodes::InnerJoin.new(
|
|
247
|
+
product_categories,
|
|
248
|
+
Arel::Nodes::On.new(product_categories[:category_id].eq(descendants[:id]))
|
|
249
|
+
)
|
|
250
|
+
).
|
|
251
|
+
where(nodes[:id].in(categories.map(&:id))).
|
|
252
|
+
group(nodes[:id])
|
|
253
|
+
|
|
254
|
+
relation.distinct.count(product_categories[:product_id])
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# The breadcrumb trail, with the images a serializer renders alongside
|
|
258
|
+
# each crumb already loaded. `ancestors` comes from the nested set and
|
|
259
|
+
# preloads nothing, so drawing a trail would otherwise cost two
|
|
260
|
+
# attachment lookups per level.
|
|
261
|
+
#
|
|
262
|
+
# @return [ActiveRecord::Relation<Spree::Category>]
|
|
263
|
+
def ancestors
|
|
264
|
+
super.with_attached_image.with_attached_square_image
|
|
265
|
+
end
|
|
266
|
+
|
|
196
267
|
def slug
|
|
197
268
|
permalink
|
|
198
269
|
end
|
data/app/models/spree/channel.rb
CHANGED
|
@@ -62,6 +62,47 @@ module Spree
|
|
|
62
62
|
self.whitelisted_ransackable_attributes = %w[name code active default store_id]
|
|
63
63
|
search_by :name
|
|
64
64
|
|
|
65
|
+
# Publishes the given products on every channel in the current scope
|
|
66
|
+
# (`store.channels.where(id: ids).add_products(product_ids)`), touching
|
|
67
|
+
# and reindexing each product once rather than once per channel.
|
|
68
|
+
# @param product_ids [Array<Integer>, Integer]
|
|
69
|
+
# @return [Integer] the number of ProductPublications created or updated
|
|
70
|
+
def self.add_products(product_ids)
|
|
71
|
+
product_ids = Array(product_ids).map(&:to_s).uniq
|
|
72
|
+
channels = all.to_a
|
|
73
|
+
return 0 if product_ids.empty? || channels.empty?
|
|
74
|
+
|
|
75
|
+
count = channels.sum { |channel| channel.send(:upsert_publications, product_ids) }
|
|
76
|
+
refresh_products(product_ids)
|
|
77
|
+
channels.each(&:touch)
|
|
78
|
+
count
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Unpublishes the given products from every channel in the current scope.
|
|
82
|
+
# @param product_ids [Array<Integer>, Integer]
|
|
83
|
+
# @return [Integer] the number of ProductPublications destroyed
|
|
84
|
+
def self.remove_products(product_ids)
|
|
85
|
+
product_ids = Array(product_ids).map(&:to_s).uniq
|
|
86
|
+
channels = all.to_a
|
|
87
|
+
return 0 if product_ids.empty? || channels.empty?
|
|
88
|
+
|
|
89
|
+
removed = Spree::ProductPublication.includes(:product).where(channel_id: channels.map(&:id), product_id: product_ids).destroy_all
|
|
90
|
+
refresh_products(product_ids)
|
|
91
|
+
emptied_channel_ids = removed.map(&:channel_id).uniq
|
|
92
|
+
channels.select { |channel| emptied_channel_ids.include?(channel.id) }.each(&:touch)
|
|
93
|
+
removed.size
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @param product_ids [Array<String>]
|
|
97
|
+
# @return [void]
|
|
98
|
+
def self.refresh_products(product_ids)
|
|
99
|
+
products = Spree::Product.where(id: product_ids)
|
|
100
|
+
products.touch_all
|
|
101
|
+
# `to_a` once: `touch_all` does not load the relation, so iterating it
|
|
102
|
+
# afterwards would re-run the query.
|
|
103
|
+
products.to_a.each(&:enqueue_search_index)
|
|
104
|
+
end
|
|
105
|
+
|
|
65
106
|
# Publishes the given products on this channel by creating/upserting ProductPublications.
|
|
66
107
|
# Optionally sets the publication window; if not given, the products will be published immediately
|
|
67
108
|
# with no end date.
|
|
@@ -73,42 +114,10 @@ module Spree
|
|
|
73
114
|
product_ids = Array(product_ids).map(&:to_s).uniq
|
|
74
115
|
return 0 if product_ids.empty?
|
|
75
116
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
# explicitly passed a value. Leaving them out keeps existing
|
|
79
|
-
# publication schedules intact on re-publish — otherwise +on_duplicate:
|
|
80
|
-
# :update+ + +update_only+ would rewrite scheduled +published_at+ /
|
|
81
|
-
# +unpublished_at+ to NULL whenever the bulk action re-runs without
|
|
82
|
-
# dates.
|
|
83
|
-
base = { channel_id: id, created_at: now, updated_at: now }
|
|
84
|
-
base[:published_at] = published_at unless published_at.nil?
|
|
85
|
-
base[:unpublished_at] = unpublished_at unless unpublished_at.nil?
|
|
86
|
-
|
|
87
|
-
records_to_upsert = product_ids.map { |product_id| base.merge(product_id: product_id) }
|
|
88
|
-
|
|
89
|
-
# Only update the window columns the caller passed. When neither was
|
|
90
|
-
# passed, treat re-publish as a no-op (+on_duplicate: :skip+ → MySQL
|
|
91
|
-
# +INSERT IGNORE+, PG/SQLite +ON CONFLICT DO NOTHING+).
|
|
92
|
-
update_columns = []
|
|
93
|
-
update_columns << :published_at unless published_at.nil?
|
|
94
|
-
update_columns << :unpublished_at unless unpublished_at.nil?
|
|
95
|
-
opts = if update_columns.empty?
|
|
96
|
-
{ on_duplicate: :skip }
|
|
97
|
-
else
|
|
98
|
-
{ record_timestamps: false, update_only: update_columns, on_duplicate: :update }
|
|
99
|
-
end
|
|
100
|
-
# MySQL infers the conflict target from the table's unique constraints
|
|
101
|
-
# and rejects an explicit +unique_by+; PostgreSQL/SQLite require it.
|
|
102
|
-
opts[:unique_by] = %i[product_id channel_id] unless mysql_adapter?
|
|
103
|
-
|
|
104
|
-
Spree::ProductPublication.upsert_all(records_to_upsert, **opts)
|
|
105
|
-
|
|
106
|
-
products = Spree::Product.where(id: product_ids)
|
|
107
|
-
products.touch_all
|
|
108
|
-
products.each(&:enqueue_search_index)
|
|
117
|
+
count = upsert_publications(product_ids, published_at: published_at, unpublished_at: unpublished_at)
|
|
118
|
+
self.class.refresh_products(product_ids)
|
|
109
119
|
touch
|
|
110
|
-
|
|
111
|
-
records_to_upsert.size
|
|
120
|
+
count
|
|
112
121
|
end
|
|
113
122
|
|
|
114
123
|
# Unpublishes the given products from this channel.
|
|
@@ -118,12 +127,9 @@ module Spree
|
|
|
118
127
|
product_ids = Array(product_ids).map(&:to_s).uniq
|
|
119
128
|
return 0 if product_ids.empty?
|
|
120
129
|
|
|
121
|
-
count = publications.where(product_id: product_ids).destroy_all.size
|
|
122
|
-
|
|
123
|
-
products = Spree::Product.where(id: product_ids)
|
|
124
|
-
products.touch_all
|
|
125
|
-
products.each(&:enqueue_search_index)
|
|
130
|
+
count = publications.includes(:product).where(product_id: product_ids).destroy_all.size
|
|
126
131
|
|
|
132
|
+
self.class.refresh_products(product_ids)
|
|
127
133
|
touch if count.positive?
|
|
128
134
|
count
|
|
129
135
|
end
|
|
@@ -161,6 +167,39 @@ module Spree
|
|
|
161
167
|
|
|
162
168
|
private
|
|
163
169
|
|
|
170
|
+
def upsert_publications(product_ids, published_at: nil, unpublished_at: nil)
|
|
171
|
+
now = Time.current
|
|
172
|
+
# Only include window columns in the upsert payload when the caller
|
|
173
|
+
# explicitly passed a value. Leaving them out keeps existing
|
|
174
|
+
# publication schedules intact on re-publish — otherwise +on_duplicate:
|
|
175
|
+
# :update+ + +update_only+ would rewrite scheduled +published_at+ /
|
|
176
|
+
# +unpublished_at+ to NULL whenever the bulk action re-runs without
|
|
177
|
+
# dates.
|
|
178
|
+
base = { channel_id: id, created_at: now, updated_at: now }
|
|
179
|
+
base[:published_at] = published_at unless published_at.nil?
|
|
180
|
+
base[:unpublished_at] = unpublished_at unless unpublished_at.nil?
|
|
181
|
+
|
|
182
|
+
records_to_upsert = product_ids.map { |product_id| base.merge(product_id: product_id) }
|
|
183
|
+
|
|
184
|
+
# Only update the window columns the caller passed. When neither was
|
|
185
|
+
# passed, treat re-publish as a no-op (+on_duplicate: :skip+ → MySQL
|
|
186
|
+
# +INSERT IGNORE+, PG/SQLite +ON CONFLICT DO NOTHING+).
|
|
187
|
+
update_columns = []
|
|
188
|
+
update_columns << :published_at unless published_at.nil?
|
|
189
|
+
update_columns << :unpublished_at unless unpublished_at.nil?
|
|
190
|
+
opts = if update_columns.empty?
|
|
191
|
+
{ on_duplicate: :skip }
|
|
192
|
+
else
|
|
193
|
+
{ record_timestamps: false, update_only: update_columns, on_duplicate: :update }
|
|
194
|
+
end
|
|
195
|
+
# MySQL infers the conflict target from the table's unique constraints
|
|
196
|
+
# and rejects an explicit +unique_by+; PostgreSQL/SQLite require it.
|
|
197
|
+
opts[:unique_by] = %i[product_id channel_id] unless mysql_adapter?
|
|
198
|
+
|
|
199
|
+
Spree::ProductPublication.upsert_all(records_to_upsert, **opts)
|
|
200
|
+
records_to_upsert.size
|
|
201
|
+
end
|
|
202
|
+
|
|
164
203
|
def ensure_not_default
|
|
165
204
|
return unless default?
|
|
166
205
|
return if destroyed_by_association.present?
|
data/app/models/spree/company.rb
CHANGED
|
@@ -177,10 +177,47 @@ module Spree
|
|
|
177
177
|
self_and_descendants.where.not(id: id)
|
|
178
178
|
end
|
|
179
179
|
|
|
180
|
+
# Distinct members across each node's whole subtree, for many nodes in
|
|
181
|
+
# one query.
|
|
182
|
+
#
|
|
183
|
+
# @param company_ids [Array] the subtree roots
|
|
184
|
+
# @return [Hash{Integer => Integer}] member count by root id; roots with none are absent
|
|
185
|
+
def self.members_counts_for(company_ids)
|
|
186
|
+
return {} if company_ids.empty?
|
|
187
|
+
|
|
188
|
+
membership_table = Spree::CompanyMembership.table_name
|
|
189
|
+
subtree_sql = sanitize_sql_array([<<~SQL.squish, company_ids])
|
|
190
|
+
WITH RECURSIVE subtree(root_id, id) AS (
|
|
191
|
+
SELECT id, id FROM #{table_name} WHERE id IN (?)
|
|
192
|
+
UNION
|
|
193
|
+
SELECT subtree.root_id, child.id FROM #{table_name} child
|
|
194
|
+
INNER JOIN subtree ON child.parent_id = subtree.id
|
|
195
|
+
)
|
|
196
|
+
SELECT root_id, id FROM subtree
|
|
197
|
+
SQL
|
|
198
|
+
|
|
199
|
+
Spree::CompanyMembership.
|
|
200
|
+
joins("INNER JOIN (#{subtree_sql}) company_subtree ON company_subtree.id = #{membership_table}.company_id").
|
|
201
|
+
group('company_subtree.root_id').
|
|
202
|
+
distinct.count(:customer_id)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# On a lazily preloaded list, the first call counts every node of the
|
|
206
|
+
# list at once.
|
|
207
|
+
#
|
|
180
208
|
# @return [Integer]
|
|
181
209
|
def members_count
|
|
182
|
-
|
|
183
|
-
|
|
210
|
+
return @members_count if defined?(@members_count)
|
|
211
|
+
|
|
212
|
+
siblings = lazy_preload_context&.records&.grep(self.class)
|
|
213
|
+
if siblings.blank?
|
|
214
|
+
return Spree::CompanyMembership.where(company_id: self_and_descendants.select(:id)).
|
|
215
|
+
distinct.count(:customer_id)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
counts = self.class.members_counts_for(siblings.map(&:id))
|
|
219
|
+
siblings.each { |company| company.preloaded_members_count = counts.fetch(company.id, 0) }
|
|
220
|
+
@members_count
|
|
184
221
|
end
|
|
185
222
|
|
|
186
223
|
# The single tax anchor: the nearest self-or-ancestor +company+ node. Tax
|
|
@@ -248,6 +285,12 @@ module Spree
|
|
|
248
285
|
Spree::Address.not_deleted.where(owner_type: 'Spree::Company', owner_id: self_and_ancestors.map(&:id))
|
|
249
286
|
end
|
|
250
287
|
|
|
288
|
+
protected
|
|
289
|
+
|
|
290
|
+
def preloaded_members_count=(count)
|
|
291
|
+
@members_count = count
|
|
292
|
+
end
|
|
293
|
+
|
|
251
294
|
private
|
|
252
295
|
|
|
253
296
|
def ensure_can_be_deleted
|
|
@@ -16,7 +16,7 @@ module Spree
|
|
|
16
16
|
include Spree::AccountLockout
|
|
17
17
|
include Spree::PasswordPolicy
|
|
18
18
|
|
|
19
|
-
validates :email, presence: true, uniqueness: { case_sensitive: false }
|
|
19
|
+
validates :email, presence: true, uniqueness: { case_sensitive: false, scope: spree_base_uniqueness_scope }
|
|
20
20
|
|
|
21
21
|
attribute :accepts_email_marketing, :boolean, default: false
|
|
22
22
|
|
|
@@ -21,7 +21,10 @@ module Spree
|
|
|
21
21
|
# own association (rather than `alias_method`) is what lets `customer_ids=`
|
|
22
22
|
# exist and what makes the `PrefixedId` auto-decoder in `assign_attributes`
|
|
23
23
|
# recognise the key — that lookup keys off `reflect_on_association(:customers)`.
|
|
24
|
-
|
|
24
|
+
# Joining a group changes nothing on the customer, so saving the group
|
|
25
|
+
# does not re-validate every member it lists.
|
|
26
|
+
has_many :customers, through: :customer_group_users, source: :customer, source_type: Spree.customer_class.to_s,
|
|
27
|
+
validate: false
|
|
25
28
|
|
|
26
29
|
#
|
|
27
30
|
# Validations
|
|
@@ -41,64 +44,79 @@ module Spree
|
|
|
41
44
|
# @param user_ids [Array] array of user IDs to add
|
|
42
45
|
# @return [Integer] number of customers added
|
|
43
46
|
def add_customers(user_ids)
|
|
44
|
-
|
|
47
|
+
self.class.add_customers_to([self], user_ids)
|
|
48
|
+
end
|
|
45
49
|
|
|
46
|
-
|
|
47
|
-
|
|
50
|
+
# Bulk remove customers from the group
|
|
51
|
+
# @param user_ids [Array] array of user IDs to remove
|
|
52
|
+
# @return [Integer] number of customers removed
|
|
53
|
+
def remove_customers(user_ids)
|
|
54
|
+
self.class.remove_customers_from([self], user_ids)
|
|
55
|
+
end
|
|
48
56
|
|
|
49
|
-
|
|
50
|
-
|
|
57
|
+
# Adds customers to every given group, skipping memberships that already
|
|
58
|
+
# exist, with one lookup for all groups.
|
|
59
|
+
# @param groups [Array<Spree::CustomerGroup>]
|
|
60
|
+
# @param customer_ids [Array] customer IDs to add
|
|
61
|
+
# @return [Integer] number of memberships created
|
|
62
|
+
def self.add_customers_to(groups, customer_ids)
|
|
63
|
+
customer_ids = Array(customer_ids).compact_blank.map(&:to_s).uniq
|
|
64
|
+
groups = Array(groups)
|
|
65
|
+
return 0 if customer_ids.empty? || groups.empty?
|
|
66
|
+
|
|
67
|
+
existing_pairs = Spree::CustomerGroupUser.
|
|
68
|
+
where(customer_group_id: groups.map(&:id), customer_id: customer_ids).
|
|
69
|
+
pluck(:customer_group_id, :customer_id).
|
|
70
|
+
to_set { |group_id, customer_id| [group_id, customer_id.to_s] }
|
|
51
71
|
|
|
52
72
|
now = Time.current
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
73
|
+
customer_type = Spree.customer_class.to_s
|
|
74
|
+
records_to_insert = groups.flat_map do |group|
|
|
75
|
+
customer_ids.filter_map do |customer_id|
|
|
76
|
+
next if existing_pairs.include?([group.id, customer_id])
|
|
77
|
+
|
|
78
|
+
{
|
|
79
|
+
customer_group_id: group.id,
|
|
80
|
+
customer_id: customer_id,
|
|
81
|
+
customer_type: customer_type,
|
|
82
|
+
created_at: now,
|
|
83
|
+
updated_at: now
|
|
84
|
+
}
|
|
85
|
+
end
|
|
65
86
|
end
|
|
66
|
-
|
|
67
87
|
return 0 if records_to_insert.empty?
|
|
68
88
|
|
|
69
89
|
Spree::CustomerGroupUser.upsert_all(records_to_insert, on_duplicate: :skip)
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
touch
|
|
90
|
+
touch_customers(records_to_insert.map { |record| record[:customer_id] }.uniq)
|
|
91
|
+
changed_group_ids = records_to_insert.map { |record| record[:customer_group_id] }.to_set
|
|
92
|
+
groups.select { |group| changed_group_ids.include?(group.id) }.each(&:touch)
|
|
73
93
|
|
|
74
94
|
records_to_insert.size
|
|
75
95
|
end
|
|
76
96
|
|
|
77
|
-
#
|
|
78
|
-
# @param
|
|
79
|
-
# @
|
|
80
|
-
|
|
81
|
-
|
|
97
|
+
# Removes customers from every given group.
|
|
98
|
+
# @param groups [Array<Spree::CustomerGroup>]
|
|
99
|
+
# @param customer_ids [Array] customer IDs to remove
|
|
100
|
+
# @return [Integer] number of memberships removed
|
|
101
|
+
def self.remove_customers_from(groups, customer_ids)
|
|
102
|
+
customer_ids = Array(customer_ids).compact_blank.map(&:to_s).uniq
|
|
103
|
+
groups = Array(groups)
|
|
104
|
+
return 0 if customer_ids.empty? || groups.empty?
|
|
82
105
|
|
|
83
|
-
|
|
84
|
-
|
|
106
|
+
memberships = Spree::CustomerGroupUser.where(customer_group_id: groups.map(&:id), customer_id: customer_ids)
|
|
107
|
+
changed_group_ids = memberships.distinct.pluck(:customer_group_id).to_set
|
|
108
|
+
return 0 if changed_group_ids.empty?
|
|
85
109
|
|
|
86
|
-
deleted_count =
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
touch_users(user_ids)
|
|
90
|
-
touch
|
|
91
|
-
end
|
|
110
|
+
deleted_count = memberships.delete_all
|
|
111
|
+
touch_customers(customer_ids)
|
|
112
|
+
groups.select { |group| changed_group_ids.include?(group.id) }.each(&:touch)
|
|
92
113
|
|
|
93
114
|
deleted_count
|
|
94
115
|
end
|
|
95
116
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def touch_users(user_ids)
|
|
99
|
-
return if user_ids.blank?
|
|
100
|
-
|
|
101
|
-
Spree.customer_class.where(id: user_ids).touch_all
|
|
117
|
+
def self.touch_customers(customer_ids)
|
|
118
|
+
Spree.customer_class.where(id: customer_ids).touch_all
|
|
102
119
|
end
|
|
120
|
+
private_class_method :touch_customers
|
|
103
121
|
end
|
|
104
122
|
end
|
|
@@ -451,10 +451,13 @@ module Spree
|
|
|
451
451
|
calculator.description + ': ' +
|
|
452
452
|
|
|
453
453
|
if calculator.is_a?(Spree::Calculator::Shipping::FlatRate)
|
|
454
|
-
|
|
454
|
+
amount = calculator.amount_for(calculator.preferred_currency)
|
|
455
|
+
if amount.nil?
|
|
456
|
+
''
|
|
457
|
+
elsif amount.zero?
|
|
455
458
|
I18n.t('spree.free')
|
|
456
459
|
else
|
|
457
|
-
Spree::Money.new(
|
|
460
|
+
Spree::Money.new(amount, { currency: calculator.preferred_currency }).to_s
|
|
458
461
|
end
|
|
459
462
|
elsif calculator.is_a?(Spree::Calculator::Shipping::FlexiRate)
|
|
460
463
|
Spree::Money.new(calculator.preferred_first_item, { currency: calculator.preferred_currency }).to_s
|
|
@@ -497,14 +500,21 @@ module Spree
|
|
|
497
500
|
reconcile_services(pending)
|
|
498
501
|
end
|
|
499
502
|
|
|
503
|
+
# Matched against the method's rows loaded once, rather than a lookup per
|
|
504
|
+
# row. A row created here joins that list, so a later row naming the
|
|
505
|
+
# same (carrier, service) updates it as a lookup would.
|
|
500
506
|
def reconcile_services(rows)
|
|
507
|
+
existing = services.reload.to_a
|
|
501
508
|
kept_ids = rows.filter_map do |row|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
record
|
|
509
|
+
if row[:id].present?
|
|
510
|
+
id = Spree::PrefixedId.prefixed_id?(row[:id]) ? Spree::PrefixedId.decode_prefixed_id(row[:id]) : row[:id]
|
|
511
|
+
record = existing.find { |service| service.id.to_s == id.to_s }
|
|
512
|
+
end
|
|
513
|
+
record ||= existing.find { |service| service.carrier == row[:carrier] && service.service == row[:service] }
|
|
514
|
+
unless record
|
|
515
|
+
record = services.build(carrier: row[:carrier], service: row[:service])
|
|
516
|
+
existing << record
|
|
517
|
+
end
|
|
508
518
|
record.assign_attributes(row.slice(:carrier, :service, :label, :markup_flat, :markup_percent, :position).to_h)
|
|
509
519
|
record.save!
|
|
510
520
|
record.id
|
|
@@ -123,6 +123,24 @@ module Spree
|
|
|
123
123
|
delivery_origin_groups.first
|
|
124
124
|
end
|
|
125
125
|
|
|
126
|
+
# How many products use this profile. Counted for every profile of a
|
|
127
|
+
# lazily preloaded list in one grouped query, since loading the products
|
|
128
|
+
# themselves to count them would fetch the whole catalog.
|
|
129
|
+
#
|
|
130
|
+
# @return [Integer]
|
|
131
|
+
def products_count
|
|
132
|
+
return @products_count if defined?(@products_count)
|
|
133
|
+
|
|
134
|
+
profiles = lazy_preload_context&.records.presence || [self]
|
|
135
|
+
counts = Spree::Product.where(delivery_profile_id: profiles.map(&:id)).group(:delivery_profile_id).count
|
|
136
|
+
profiles.each { |profile| profile.products_count = counts.fetch(profile.id, 0) }
|
|
137
|
+
@products_count
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
protected
|
|
141
|
+
|
|
142
|
+
attr_writer :products_count
|
|
143
|
+
|
|
126
144
|
private
|
|
127
145
|
|
|
128
146
|
def create_default_origin_group
|
|
@@ -25,6 +25,20 @@ module Spree
|
|
|
25
25
|
# and provider-backed assets (which carry no file) are exempt.
|
|
26
26
|
DEFAULT_PROVIDER = 'Spree::DigitalAssetProvider::File'.freeze
|
|
27
27
|
|
|
28
|
+
# What a seller may upload unless the marketplace says otherwise: the
|
|
29
|
+
# formats digital goods usually come in. Executables, scripts and
|
|
30
|
+
# unrecognised binaries are left out on purpose, as is SVG, which can
|
|
31
|
+
# carry scripts. A marketplace selling a niche format adds it in settings.
|
|
32
|
+
# The text formats have no signature, so they are taken at the type the
|
|
33
|
+
# upload declared.
|
|
34
|
+
DEFAULT_SELLER_CONTENT_TYPES = %w[
|
|
35
|
+
application/pdf application/epub+zip application/zip application/x-7z-compressed application/gzip
|
|
36
|
+
application/json text/plain text/csv
|
|
37
|
+
audio/mpeg audio/mp4 audio/aac audio/flac audio/ogg audio/wav audio/x-wav audio/midi
|
|
38
|
+
video/mp4 video/webm video/quicktime
|
|
39
|
+
image/jpeg image/png image/gif image/webp image/tiff
|
|
40
|
+
].freeze
|
|
41
|
+
|
|
28
42
|
# The API names a provider by its shorthand (`file`); the column keeps the
|
|
29
43
|
# class name.
|
|
30
44
|
normalizes :provider_type, with: ->(value) { Spree::ApiTyped.class_name_for(Spree.digital_asset_providers, value) }
|
data/app/models/spree/event.rb
CHANGED
|
@@ -86,14 +86,29 @@ module Spree
|
|
|
86
86
|
# @return [Boolean]
|
|
87
87
|
def self.matches?(event_name, pattern)
|
|
88
88
|
return true if pattern == '*'
|
|
89
|
+
return event_name == pattern unless pattern.include?('*')
|
|
89
90
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
91
|
+
wildcard_match?(event_name, pattern)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# `*` stands for any run of characters, as in a shell glob. The comparison
|
|
95
|
+
# is plain string searching so a pattern taken from a merchant never
|
|
96
|
+
# reaches the regexp engine, which has had memory bugs in pattern parsing.
|
|
97
|
+
def self.wildcard_match?(event_name, pattern)
|
|
98
|
+
head, *middle, tail = pattern.split('*', -1)
|
|
99
|
+
return false unless event_name.start_with?(head) && event_name.end_with?(tail)
|
|
100
|
+
return false if event_name.length < head.length + tail.length
|
|
101
|
+
|
|
102
|
+
position = head.length
|
|
103
|
+
limit = event_name.length - tail.length
|
|
104
|
+
middle.all? do |segment|
|
|
105
|
+
found = event_name.index(segment, position)
|
|
106
|
+
next false if found.nil? || found + segment.length > limit
|
|
107
|
+
|
|
108
|
+
position = found + segment.length
|
|
95
109
|
end
|
|
96
110
|
end
|
|
111
|
+
private_class_method :wildcard_match?
|
|
97
112
|
|
|
98
113
|
alias_method :to_h, :attributes
|
|
99
114
|
|
|
@@ -653,7 +653,14 @@ module Spree
|
|
|
653
653
|
#
|
|
654
654
|
# @return [Spree::DeliveryMethod]
|
|
655
655
|
def delivery_method
|
|
656
|
-
|
|
656
|
+
selected = selected_delivery_rate
|
|
657
|
+
# The same row read through the rate list, when that list is to hand,
|
|
658
|
+
# so its method loads with every other rate's instead of on its own.
|
|
659
|
+
if selected && read_association_in_memory?(:delivery_rates)
|
|
660
|
+
selected = delivery_rates.detect { |rate| rate.id == selected.id } || selected
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
selected&.delivery_method || delivery_rates.first&.delivery_method
|
|
657
664
|
end
|
|
658
665
|
|
|
659
666
|
# The fulfillment provider strategy handling this fulfillment's
|
|
@@ -138,7 +138,9 @@ module Spree
|
|
|
138
138
|
end
|
|
139
139
|
|
|
140
140
|
def reload_shipment_inventory_units
|
|
141
|
-
[current_shipment, desired_shipment]
|
|
141
|
+
shipments = [current_shipment, desired_shipment]
|
|
142
|
+
shipments.each { |shipment| shipment.association(:fulfillment_items).reset }
|
|
143
|
+
ActiveRecord::Associations::Preloader.new(records: shipments, associations: :fulfillment_items).call
|
|
142
144
|
end
|
|
143
145
|
|
|
144
146
|
def current_quantity
|
|
@@ -107,6 +107,17 @@ module Spree
|
|
|
107
107
|
split
|
|
108
108
|
end
|
|
109
109
|
|
|
110
|
+
# `dup` copies the foreign keys but not the loaded records, so every copy
|
|
111
|
+
# made while packing and splitting would look its line item and variant
|
|
112
|
+
# up again.
|
|
113
|
+
def initialize_dup(other)
|
|
114
|
+
super
|
|
115
|
+
%i[line_item variant].each do |name|
|
|
116
|
+
association = other.association(name)
|
|
117
|
+
self.association(name).target = association.target if association.loaded?
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
110
121
|
# This will fail if extract >= available_quantity
|
|
111
122
|
def split_inventory!(extract_quantity)
|
|
112
123
|
split = self.class.split(self, extract_quantity)
|
data/app/models/spree/import.rb
CHANGED
|
@@ -215,10 +215,22 @@ module Spree
|
|
|
215
215
|
end
|
|
216
216
|
|
|
217
217
|
# Per-status row counts for API status payloads, memoized so one request
|
|
218
|
-
# issues a single grouped COUNT instead of one per status
|
|
218
|
+
# issues a single grouped COUNT instead of one per status — and, on a
|
|
219
|
+
# lazily preloaded list, one for every import of the list.
|
|
219
220
|
# @return [Hash{String => Integer}]
|
|
220
221
|
def rows_status_counts
|
|
221
|
-
@rows_status_counts
|
|
222
|
+
return @rows_status_counts if @rows_status_counts
|
|
223
|
+
|
|
224
|
+
siblings = lazy_preload_context&.records&.grep(Spree::Import)
|
|
225
|
+
return @rows_status_counts = rows.group(:status).count if siblings.blank?
|
|
226
|
+
|
|
227
|
+
counts = Spree::ImportRow.where(import_id: siblings.map(&:id)).group(:import_id, :status).count
|
|
228
|
+
siblings.each do |import|
|
|
229
|
+
import.preloaded_rows_status_counts = counts.each_with_object({}) do |((import_id, status), count), by_status|
|
|
230
|
+
by_status[status] = count if import_id == import.id
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
@rows_status_counts
|
|
222
234
|
end
|
|
223
235
|
|
|
224
236
|
# Public API name for the +results_url+ preference (read/write symmetry).
|
|
@@ -474,6 +486,12 @@ module Spree
|
|
|
474
486
|
end
|
|
475
487
|
end
|
|
476
488
|
|
|
489
|
+
protected
|
|
490
|
+
|
|
491
|
+
def preloaded_rows_status_counts=(counts)
|
|
492
|
+
@rows_status_counts = counts
|
|
493
|
+
end
|
|
494
|
+
|
|
477
495
|
private
|
|
478
496
|
|
|
479
497
|
# Mirrors the machine's bang events: an illegal move raised.
|