spree_core 5.5.3 → 5.6.0.rc2
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/helpers/spree/base_helper.rb +12 -2
- data/app/helpers/spree/currency_helper.rb +1 -4
- data/app/helpers/spree/locale_helper.rb +18 -13
- data/app/helpers/spree/localized_names_helper.rb +25 -0
- data/app/mailers/spree/admin_user_mailer.rb +71 -0
- data/app/mailers/spree/base_mailer.rb +73 -11
- data/app/mailers/spree/export_mailer.rb +7 -7
- data/app/mailers/spree/import_mailer.rb +19 -0
- data/app/mailers/spree/invitation_mailer.rb +16 -11
- data/app/mailers/spree/report_mailer.rb +6 -6
- data/app/mailers/spree/webhook_mailer.rb +7 -6
- data/app/models/concerns/spree/admin_user_methods.rb +44 -0
- data/app/models/concerns/spree/legacy_multi_store_support.rb +48 -0
- data/app/models/concerns/spree/presentation_translatable.rb +39 -0
- data/app/models/concerns/spree/product_scopes.rb +21 -3
- data/app/models/concerns/spree/single_store_resource.rb +5 -0
- data/app/models/concerns/spree/store_scoped_resource.rb +10 -0
- data/app/models/concerns/spree/stores/markets.rb +9 -1
- data/app/models/concerns/spree/stores/setup.rb +37 -23
- data/app/models/concerns/spree/translatable_resource.rb +95 -5
- data/app/models/concerns/spree/user_methods.rb +47 -2
- data/app/models/spree/ability.rb +5 -4
- data/app/models/spree/allowed_origin.rb +31 -0
- data/app/models/spree/category.rb +12 -2
- data/app/models/spree/channel/gating.rb +67 -0
- data/app/models/spree/channel.rb +1 -0
- data/app/models/spree/classification.rb +12 -0
- data/app/models/spree/country.rb +22 -1
- data/app/models/spree/credit_card.rb +29 -0
- data/app/models/spree/currency.rb +47 -0
- data/app/models/spree/current.rb +12 -1
- data/app/models/spree/customer_group.rb +1 -0
- data/app/models/spree/export.rb +19 -0
- data/app/models/spree/exports/coupon_codes.rb +1 -2
- data/app/models/spree/import.rb +84 -1
- data/app/models/spree/import_row.rb +7 -1
- data/app/models/spree/imports/product_translations.rb +6 -0
- data/app/models/spree/locale.rb +104 -0
- data/app/models/spree/newsletter_subscriber.rb +4 -0
- data/app/models/spree/option_type.rb +11 -13
- data/app/models/spree/option_value.rb +5 -17
- data/app/models/spree/order.rb +19 -2
- data/app/models/spree/payment_method.rb +6 -5
- data/app/models/spree/permission_sets/default_customer.rb +3 -0
- data/app/models/spree/policy.rb +2 -1
- data/app/models/spree/product.rb +23 -2
- data/app/models/spree/promotion/rules/channel.rb +37 -0
- data/app/models/spree/promotion/rules/market.rb +37 -0
- data/app/models/spree/promotion.rb +5 -3
- data/app/models/spree/promotion_handler/promotion_duplicator.rb +1 -1
- data/app/models/spree/role_user.rb +4 -0
- data/app/models/spree/search_provider/meilisearch.rb +22 -9
- data/app/models/spree/setup_task.rb +12 -0
- data/app/models/spree/setup_tasks.rb +86 -0
- data/app/models/spree/shipment.rb +38 -1
- data/app/models/spree/shipment_handler.rb +1 -1
- data/app/models/spree/stock/availability_validator.rb +1 -1
- data/app/models/spree/stock/quantifier.rb +21 -1
- data/app/models/spree/stock_item.rb +2 -1
- data/app/models/spree/stock_location.rb +4 -1
- data/app/models/spree/store.rb +45 -9
- data/app/models/spree/store_payment_method.rb +5 -1
- data/app/models/spree/store_promotion.rb +4 -0
- data/app/models/spree/taxon.rb +112 -9
- data/app/models/spree/taxonomy.rb +1 -1
- data/app/models/spree/translations/batch.rb +104 -0
- data/app/models/spree/user_identity.rb +35 -24
- data/app/models/spree/variant.rb +29 -2
- data/app/presenters/spree/search_provider/product_presenter.rb +3 -0
- data/app/services/spree/carts/complete.rb +3 -0
- data/app/services/spree/fulfillments/create.rb +259 -0
- data/app/services/spree/locales/set_fallback_locale_for_store.rb +7 -1
- data/app/services/spree/locales.rb +52 -0
- data/app/services/spree/newsletter/subscribe.rb +1 -0
- data/app/services/spree/products/prepare_nested_attributes.rb +40 -12
- data/app/services/spree/shipments/update.rb +1 -1
- data/app/services/spree/stock_reservations/reserve.rb +4 -1
- data/app/services/spree/taxons/add_products.rb +3 -0
- data/app/services/spree/taxons/remove_products.rb +3 -0
- data/app/subscribers/spree/admin_user_email_subscriber.rb +29 -0
- data/app/subscribers/spree/import_email_subscriber.rb +26 -0
- data/app/views/layouts/spree/base_mailer.html.erb +14 -17
- data/app/views/spree/admin_user_mailer/confirmation_email.html.erb +7 -0
- data/app/views/spree/admin_user_mailer/password_reset_email.html.erb +8 -0
- data/app/views/spree/export_mailer/export_done.html.erb +6 -14
- data/app/views/spree/import_mailer/import_done.html.erb +12 -0
- data/app/views/spree/invitation_mailer/invitation_accepted.html.erb +4 -12
- data/app/views/spree/invitation_mailer/invitation_email.html.erb +11 -24
- data/app/views/spree/report_mailer/report_done.html.erb +6 -14
- data/app/views/spree/shared/_base_mailer_footer.html.erb +15 -8
- data/app/views/spree/shared/_base_mailer_header.html.erb +7 -5
- data/app/views/spree/shared/_base_mailer_stylesheets.html.erb +211 -273
- data/app/views/spree/shared/_mailer_button.html.erb +10 -0
- data/app/views/spree/shared/_mailer_hero.html.erb +12 -0
- data/app/views/spree/shared/_purchased_items_styles.html.erb +112 -0
- data/app/views/spree/webhook_mailer/endpoint_disabled.html.erb +39 -14
- data/config/initializers/spree_store_setup_tasks.rb +23 -0
- data/config/locales/en.yml +42 -992
- data/db/migrate/20260613000001_add_store_id_to_spree_role_users.rb +10 -0
- data/db/migrate/20260626000001_add_store_id_to_spree_taxons.rb +11 -0
- data/db/migrate/20260627000001_add_products_count_to_spree_taxons.rb +6 -0
- data/db/migrate/20260628000001_add_store_id_to_spree_promotions.rb +10 -0
- data/db/migrate/20260628000002_add_store_id_to_spree_payment_methods.rb +11 -0
- data/db/migrate/20260630000001_add_preorder_fields_to_spree_variants.rb +7 -0
- data/db/migrate/20260707000001_add_fingerprint_to_spree_credit_cards.rb +39 -0
- data/db/migrate/20260710000001_add_import_id_status_index_to_spree_import_rows.rb +7 -0
- data/db/migrate/20260711000001_add_preferences_to_spree_exports.rb +7 -0
- data/db/sample_data/promotions.rb +1 -1
- data/lib/generators/spree/authentication/dummy/templates/create_spree_admin_users.rb.tt +0 -1
- data/lib/mobility/plugins/store_based_fallbacks.rb +7 -5
- data/lib/spree/core/configuration.rb +1 -0
- data/lib/spree/core/controller_helpers/locale.rb +4 -1
- data/lib/spree/core/controller_helpers/strong_parameters.rb +1 -0
- data/lib/spree/core/dependencies.rb +3 -0
- data/lib/spree/core/engine.rb +24 -0
- data/lib/spree/core/preferences/preferable.rb +6 -1
- data/lib/spree/core/previews/admin_user_preview.rb +22 -0
- data/lib/spree/core/previews/export_preview.rb +37 -0
- data/lib/spree/core/previews/invitation_preview.rb +47 -0
- data/lib/spree/core/previews/preview_data.rb +52 -0
- data/lib/spree/core/previews/report_preview.rb +40 -0
- data/lib/spree/core/previews/webhook_preview.rb +34 -0
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/core.rb +38 -1
- data/lib/spree/permitted_attributes.rb +5 -4
- data/lib/spree/testing_support/factories/export_factory.rb +4 -0
- data/lib/spree/testing_support/factories/order_factory.rb +1 -1
- data/lib/spree/testing_support/factories/payment_factory.rb +3 -3
- data/lib/spree/testing_support/factories/payment_method_factory.rb +1 -9
- data/lib/spree/testing_support/factories/payment_session_factory.rb +2 -2
- data/lib/spree/testing_support/factories/promotion_factory.rb +1 -9
- data/lib/spree/testing_support/factories/promotion_rule_factory.rb +24 -0
- data/lib/spree/translations.rb +113 -0
- data/lib/spree/upgrades/5_5_to_5_6/manifest.yml +59 -0
- data/lib/tasks/role_users.rake +21 -0
- data/lib/tasks/single_store_associations.rake +65 -0
- data/lib/tasks/taxons.rake +62 -1
- metadata +47 -5
- data/app/models/spree/product/legacy_multi_store_support.rb +0 -40
data/app/models/spree/taxon.rb
CHANGED
|
@@ -4,6 +4,8 @@ require 'stringex'
|
|
|
4
4
|
|
|
5
5
|
module Spree
|
|
6
6
|
class Taxon < Spree.base_class
|
|
7
|
+
include Spree::SingleStoreResource
|
|
8
|
+
|
|
7
9
|
has_prefix_id :ctg
|
|
8
10
|
|
|
9
11
|
RULES_MATCH_POLICIES = %w[all any].freeze
|
|
@@ -37,7 +39,7 @@ module Spree
|
|
|
37
39
|
# Associations
|
|
38
40
|
#
|
|
39
41
|
belongs_to :taxonomy, class_name: 'Spree::Taxonomy', inverse_of: :taxons
|
|
40
|
-
|
|
42
|
+
belongs_to :store, class_name: 'Spree::Store', optional: true
|
|
41
43
|
has_many :classifications, -> { order(:position) }, dependent: :destroy_async, inverse_of: :taxon
|
|
42
44
|
has_many :products, through: :classifications
|
|
43
45
|
|
|
@@ -56,9 +58,16 @@ module Spree
|
|
|
56
58
|
#
|
|
57
59
|
# Validations
|
|
58
60
|
#
|
|
59
|
-
validates :name, presence: true
|
|
60
|
-
validates :taxonomy, presence: true
|
|
61
|
-
validates :
|
|
61
|
+
validates :name, presence: true
|
|
62
|
+
validates :taxonomy, presence: true, if: :requires_taxonomy?
|
|
63
|
+
validates :store, presence: true
|
|
64
|
+
# Taxonomy-backed taxons are unique within their taxonomy; taxonomy-less
|
|
65
|
+
# categories (store-owned) are unique within their store, so two stores can
|
|
66
|
+
# each have a top-level "Shoes".
|
|
67
|
+
validates :name, uniqueness: { scope: %i[parent_id taxonomy_id], case_sensitive: false }, if: :requires_taxonomy?
|
|
68
|
+
validates :permalink, uniqueness: { scope: %i[parent_id taxonomy_id], case_sensitive: false }, if: :requires_taxonomy?
|
|
69
|
+
validates :name, uniqueness: { scope: %i[parent_id store_id], case_sensitive: false }, unless: :requires_taxonomy?
|
|
70
|
+
validates :permalink, uniqueness: { scope: %i[parent_id store_id], case_sensitive: false }, unless: :requires_taxonomy?
|
|
62
71
|
validates :hide_from_nav, inclusion: { in: [true, false] }
|
|
63
72
|
validate :check_for_root, on: :create
|
|
64
73
|
validate :parent_belongs_to_same_taxonomy
|
|
@@ -81,13 +90,32 @@ module Spree
|
|
|
81
90
|
after_commit :regenerate_pretty_name_and_permalink, on: :update, if: :should_regenerate_pretty_name_and_permalink?
|
|
82
91
|
after_move :regenerate_pretty_name_and_permalink
|
|
83
92
|
after_move :regenerate_translations_pretty_name_and_permalink
|
|
93
|
+
# Moving a subtree shifts its products under a new ancestor chain (and away
|
|
94
|
+
# from the old one), so recompute both branches' inclusive products_count.
|
|
95
|
+
# Capture the old parent before the move; recompute both chains after.
|
|
96
|
+
before_move :capture_parent_before_move
|
|
97
|
+
after_move :recalculate_products_count_after_move
|
|
98
|
+
# Destroying a taxon drops its subtree's products from every ancestor's
|
|
99
|
+
# inclusive count. Recompute the ancestors here (while the doomed subtree's
|
|
100
|
+
# rows still exist) excluding that subtree, since the Classification destroy
|
|
101
|
+
# callbacks can't help: classifications are removed via destroy_async, so
|
|
102
|
+
# they outlive this callback and their taxon may already be gone.
|
|
103
|
+
before_destroy :recalculate_ancestors_before_destroy, prepend: true
|
|
84
104
|
|
|
85
105
|
#
|
|
86
106
|
# Scopes
|
|
87
107
|
#
|
|
88
|
-
|
|
89
|
-
|
|
108
|
+
# Prefer the direct store_id column; fall back to the taxonomy join for rows
|
|
109
|
+
# not yet backfilled (store_id IS NULL) so legacy behaviour is preserved.
|
|
110
|
+
scope :for_store, ->(store) { for_stores([store]) }
|
|
111
|
+
scope :for_stores, lambda { |stores|
|
|
112
|
+
store_ids = Array(stores).map(&:id)
|
|
113
|
+
taxonomy_ids = Spree::Taxonomy.where(store_id: store_ids).select(:id)
|
|
114
|
+
where(store_id: store_ids).or(where(store_id: nil, taxonomy_id: taxonomy_ids))
|
|
115
|
+
}
|
|
90
116
|
scope :for_taxonomy, lambda { |taxonomy_name|
|
|
117
|
+
Spree::Deprecation.warn('Spree::Taxon.for_taxonomy is deprecated and will be removed in Spree 6. Please use for_store instead.')
|
|
118
|
+
|
|
91
119
|
if Spree.use_translations?
|
|
92
120
|
joins(:taxonomy)
|
|
93
121
|
.join_translation_table(Taxonomy)
|
|
@@ -121,13 +149,14 @@ module Spree
|
|
|
121
149
|
#
|
|
122
150
|
self.whitelisted_ransackable_associations = %w[taxonomy parent]
|
|
123
151
|
self.whitelisted_ransackable_attributes = %w[name permalink automatic depth is_root children_count
|
|
124
|
-
classification_count hide_from_nav parent_id]
|
|
152
|
+
classification_count products_count pretty_name hide_from_nav parent_id]
|
|
125
153
|
|
|
126
154
|
#
|
|
127
155
|
# Translations
|
|
128
156
|
#
|
|
129
157
|
TRANSLATABLE_FIELDS = %i[name pretty_name description permalink].freeze
|
|
130
|
-
|
|
158
|
+
RICH_TEXT_TRANSLATABLE_FIELDS = %i[description].freeze
|
|
159
|
+
translates(*TRANSLATABLE_FIELDS, column_fallback: Spree.mobility_column_fallback)
|
|
131
160
|
|
|
132
161
|
#
|
|
133
162
|
# Action Text
|
|
@@ -154,6 +183,18 @@ module Spree
|
|
|
154
183
|
!automatic?
|
|
155
184
|
end
|
|
156
185
|
|
|
186
|
+
# The owning store. Prefers the direct +store_id+; falls back to the
|
|
187
|
+
# taxonomy's store for legacy rows not yet backfilled.
|
|
188
|
+
def store
|
|
189
|
+
super || taxonomy&.store
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Whether a taxonomy is mandatory. Legacy +Spree::Taxon+ requires one;
|
|
193
|
+
# +Spree::Category+ overrides this to false (it is owned via +store_id+).
|
|
194
|
+
def requires_taxonomy?
|
|
195
|
+
true
|
|
196
|
+
end
|
|
197
|
+
|
|
157
198
|
def manual_sort_order?
|
|
158
199
|
sort_order == 'manual'
|
|
159
200
|
end
|
|
@@ -169,6 +210,26 @@ module Spree
|
|
|
169
210
|
)
|
|
170
211
|
end
|
|
171
212
|
|
|
213
|
+
# Recomputes the stored, descendant-inclusive +products_count+ for the given
|
|
214
|
+
# taxons AND all of their ancestors — the nodes whose inclusive count can
|
|
215
|
+
# shift when a classification changes. Counts unique products classified
|
|
216
|
+
# under each node or its descendants (a product reachable through several
|
|
217
|
+
# nodes is counted once per ancestor). Call after any classification change.
|
|
218
|
+
#
|
|
219
|
+
# @param taxon_ids [Array<Integer>] taxons whose branch counts changed
|
|
220
|
+
def self.recalculate_products_count(taxon_ids)
|
|
221
|
+
changed = unscoped.where(id: Array(taxon_ids).compact.uniq)
|
|
222
|
+
|
|
223
|
+
# The affected nodes are each changed taxon plus its ancestors.
|
|
224
|
+
affected = changed.flat_map { |taxon| taxon.self_and_ancestors.to_a }.uniq(&:id)
|
|
225
|
+
|
|
226
|
+
affected.each do |taxon|
|
|
227
|
+
count = Spree::Classification.where(taxon_id: taxon.self_and_descendants.select(:id))
|
|
228
|
+
.distinct.count(:product_id)
|
|
229
|
+
taxon.update_column(:products_count, count) if taxon.products_count != count
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
172
233
|
def products_matching_rules(opts = {})
|
|
173
234
|
return Spree::Product.none if manual? || rules.empty?
|
|
174
235
|
|
|
@@ -379,6 +440,7 @@ module Spree
|
|
|
379
440
|
end
|
|
380
441
|
|
|
381
442
|
def sync_taxonomy_name
|
|
443
|
+
return unless taxonomy.present?
|
|
382
444
|
return unless saved_changes.key?(:name) && root?
|
|
383
445
|
return if taxonomy.name.to_s == name.to_s
|
|
384
446
|
|
|
@@ -389,7 +451,33 @@ module Spree
|
|
|
389
451
|
# Touches all ancestors at once to avoid recursive taxonomy touch, and reduce queries.
|
|
390
452
|
ancestors.update_all(updated_at: Time.current)
|
|
391
453
|
# Have taxonomy touch happen in #touch_ancestors_and_taxonomy rather than association option in order for imports to override.
|
|
392
|
-
taxonomy.
|
|
454
|
+
taxonomy.touch if taxonomy.present?
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def capture_parent_before_move
|
|
458
|
+
@parent_id_before_move = parent_id_in_database
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# Recompute the inclusive products_count for the moved node's new ancestor
|
|
462
|
+
# chain and the previous parent's chain too (whose subtree just lost this
|
|
463
|
+
# node's products).
|
|
464
|
+
def recalculate_products_count_after_move
|
|
465
|
+
affected = [id, @parent_id_before_move].compact
|
|
466
|
+
@parent_id_before_move = nil
|
|
467
|
+
self.class.recalculate_products_count(affected)
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# Recomputes each ancestor's inclusive products_count as if this subtree were
|
|
471
|
+
# already gone (its classifications are destroyed asynchronously, so they're
|
|
472
|
+
# still present here). Excludes self_and_descendants from the count.
|
|
473
|
+
def recalculate_ancestors_before_destroy
|
|
474
|
+
doomed_ids = self_and_descendants.ids
|
|
475
|
+
ancestors.each do |ancestor|
|
|
476
|
+
remaining = Spree::Classification.
|
|
477
|
+
where(taxon_id: ancestor.self_and_descendants.where.not(id: doomed_ids).select(:id)).
|
|
478
|
+
distinct.count(:product_id)
|
|
479
|
+
ancestor.update_column(:products_count, remaining) if ancestor.products_count != remaining
|
|
480
|
+
end
|
|
393
481
|
end
|
|
394
482
|
|
|
395
483
|
def check_for_root
|
|
@@ -408,6 +496,21 @@ module Spree
|
|
|
408
496
|
self.taxonomy = parent.taxonomy if parent.present? && taxonomy.blank?
|
|
409
497
|
end
|
|
410
498
|
|
|
499
|
+
def set_store
|
|
500
|
+
Spree::Deprecation.warn('Spree::Taxon#set_store is deprecated and will be removed in Spree 6.0. ensure_store instead.')
|
|
501
|
+
ensure_store
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
# Every taxon is store-owned. Resolve the store from the taxonomy, then the
|
|
505
|
+
# parent, finally the current store — so the direct column is always
|
|
506
|
+
# populated for new records. Guards on the raw +store_id+ column rather than
|
|
507
|
+
# +#store+, whose reader masks an unset column with the taxonomy fallback.
|
|
508
|
+
def ensure_store
|
|
509
|
+
return if store_id.present?
|
|
510
|
+
|
|
511
|
+
self.store = taxonomy&.store || parent&.store || Spree::Store.current
|
|
512
|
+
end
|
|
513
|
+
|
|
411
514
|
def regenerate_translations_pretty_name_and_permalink
|
|
412
515
|
translations.each(&:regenerate_pretty_name_and_permalink)
|
|
413
516
|
end
|
|
@@ -8,7 +8,7 @@ module Spree
|
|
|
8
8
|
include Spree::SingleStoreResource
|
|
9
9
|
|
|
10
10
|
TRANSLATABLE_FIELDS = %i[name].freeze
|
|
11
|
-
translates(*TRANSLATABLE_FIELDS, column_fallback:
|
|
11
|
+
translates(*TRANSLATABLE_FIELDS, column_fallback: Spree.mobility_column_fallback)
|
|
12
12
|
|
|
13
13
|
acts_as_list
|
|
14
14
|
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Translations
|
|
3
|
+
# Atomically upserts translations across MANY records of (possibly)
|
|
4
|
+
# different translatable resource types in one operation — the domain
|
|
5
|
+
# behind +POST /api/v3/admin/translations/batch+.
|
|
6
|
+
#
|
|
7
|
+
# The input is a flat list of independent registry writes, each naming its
|
|
8
|
+
# own +resource_type+ + +resource_id+ (no nested parent-owns-children
|
|
9
|
+
# payload), so there's no per-model branching. Records are resolved within
|
|
10
|
+
# the current store, then upserted in a single transaction: any failure
|
|
11
|
+
# rolls back the whole batch and surfaces the offending entry's index via a
|
|
12
|
+
# typed {EntryError}.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# Spree::Translations::Batch.new(entries).process! do |record|
|
|
16
|
+
# authorize!(:update, record)
|
|
17
|
+
# end
|
|
18
|
+
class Batch
|
|
19
|
+
include ActiveModel::Model
|
|
20
|
+
|
|
21
|
+
# Raised when the batch payload is empty (or not a list of entries).
|
|
22
|
+
class EmptyError < StandardError; end
|
|
23
|
+
|
|
24
|
+
# Raised when an entry can't be processed — unknown/non-translatable
|
|
25
|
+
# resource type, a record missing in the current store, or an invalid
|
|
26
|
+
# save. Carries the entry +index+ so the caller can map the error back to
|
|
27
|
+
# the offending row.
|
|
28
|
+
class EntryError < StandardError
|
|
29
|
+
attr_reader :index
|
|
30
|
+
|
|
31
|
+
def initialize(message, index)
|
|
32
|
+
@index = index
|
|
33
|
+
super(message)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Array<Spree.base_class>] records written by the last successful {#process!}
|
|
38
|
+
attr_reader :records
|
|
39
|
+
|
|
40
|
+
# @param entries [Array<Hash>] [{ resource_type:, resource_id:, values: { locale => { field => value } } }]
|
|
41
|
+
def initialize(entries)
|
|
42
|
+
@entries = Array(entries)
|
|
43
|
+
@records = []
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Distinct +write_<resource>+ scopes a caller needs to authorize this
|
|
47
|
+
# batch — one per resource type present. Lets the API layer gate an
|
|
48
|
+
# API-key request without re-deriving scope names.
|
|
49
|
+
# @return [Array<String>]
|
|
50
|
+
def required_scopes
|
|
51
|
+
@entries.map { |entry| "write_#{entry[:resource_type].to_s.pluralize}" }.uniq
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Upserts every entry in one transaction. Yields each resolved record
|
|
55
|
+
# before it is written so the caller can authorize it; a raised error in
|
|
56
|
+
# the block (or any entry failure) rolls back the whole batch.
|
|
57
|
+
#
|
|
58
|
+
# @yieldparam record [Spree.base_class] the resolved record, pre-write
|
|
59
|
+
# @raise [EmptyError] when there are no entries
|
|
60
|
+
# @raise [EntryError] when an entry can't be resolved or saved
|
|
61
|
+
# @return [Array<Spree.base_class>] the written records
|
|
62
|
+
def process!
|
|
63
|
+
raise EmptyError if @entries.empty?
|
|
64
|
+
|
|
65
|
+
@records = []
|
|
66
|
+
ActiveRecord::Base.transaction do
|
|
67
|
+
@entries.each_with_index do |entry, index|
|
|
68
|
+
record = resolve_record!(entry, index)
|
|
69
|
+
yield record if block_given?
|
|
70
|
+
upsert!(record, entry[:values], index)
|
|
71
|
+
@records << record
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
@records
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def upsert!(record, values, index)
|
|
80
|
+
record.upsert_translations(values)
|
|
81
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
82
|
+
raise EntryError.new(e.record.errors.full_messages.join(', '), index)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def resolve_record!(entry, index)
|
|
86
|
+
klass = resource_class(entry[:resource_type])
|
|
87
|
+
raise EntryError.new("Unknown translatable resource type: #{entry[:resource_type]}", index) if klass.nil?
|
|
88
|
+
|
|
89
|
+
relation = klass.respond_to?(:for_store) ? klass.for_store(Spree::Store.current) : klass
|
|
90
|
+
relation.find_by_prefix_id!(entry[:resource_id])
|
|
91
|
+
rescue ActiveRecord::RecordNotFound
|
|
92
|
+
raise EntryError.new("Resource not found: #{entry[:resource_id]}", index)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Memoized per instance so a batch of N entries doesn't rebuild the
|
|
96
|
+
# registry map N times. A Batch is request-scoped, so dev-mode class
|
|
97
|
+
# reloads are still picked up on the next request.
|
|
98
|
+
def resource_class(token)
|
|
99
|
+
@resource_class_map ||= Hash.new { |h, t| h[t] = Spree::Translations.resource_class(t) }
|
|
100
|
+
@resource_class_map[token]
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -5,10 +5,10 @@ module Spree
|
|
|
5
5
|
belongs_to :user, polymorphic: true, optional: false
|
|
6
6
|
|
|
7
7
|
validates :provider, presence: true
|
|
8
|
-
validates :uid, presence: true, uniqueness: { scope: [
|
|
8
|
+
validates :uid, presence: true, uniqueness: { scope: %i[provider user_type] }
|
|
9
9
|
|
|
10
10
|
validates :provider, inclusion: {
|
|
11
|
-
in:
|
|
11
|
+
in: lambda { |_record|
|
|
12
12
|
(Spree.store_authentication_strategies.keys + Spree.admin_authentication_strategies.keys).uniq.map(&:to_s)
|
|
13
13
|
}
|
|
14
14
|
}
|
|
@@ -27,14 +27,7 @@ module Spree
|
|
|
27
27
|
identity = find_by(provider: provider, uid: uid, user_type: user_type)
|
|
28
28
|
|
|
29
29
|
if identity
|
|
30
|
-
|
|
31
|
-
identity.update(
|
|
32
|
-
info: info,
|
|
33
|
-
access_token: tokens[:access_token],
|
|
34
|
-
refresh_token: tokens[:refresh_token],
|
|
35
|
-
expires_at: tokens[:expires_at]
|
|
36
|
-
)
|
|
37
|
-
identity.user
|
|
30
|
+
refresh_identity(identity, info: info, tokens: tokens).user
|
|
38
31
|
else
|
|
39
32
|
# Create new user and identity
|
|
40
33
|
create_user_from_oauth(
|
|
@@ -45,28 +38,46 @@ module Spree
|
|
|
45
38
|
user_class: user_class
|
|
46
39
|
)
|
|
47
40
|
end
|
|
41
|
+
rescue ActiveRecord::RecordNotUnique
|
|
42
|
+
refresh_identity(
|
|
43
|
+
find_by!(provider: provider, uid: uid, user_type: user_type),
|
|
44
|
+
info: info,
|
|
45
|
+
tokens: tokens
|
|
46
|
+
).user
|
|
48
47
|
end
|
|
49
48
|
|
|
50
|
-
def self.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
user = user_class.create!(
|
|
54
|
-
email: info[:email] || generate_temp_email(provider, uid),
|
|
55
|
-
password: SecureRandom.hex(32), # Random password for OAuth users
|
|
56
|
-
first_name: info[:first_name],
|
|
57
|
-
last_name: info[:last_name]
|
|
58
|
-
)
|
|
59
|
-
|
|
60
|
-
user.identities.create!(
|
|
61
|
-
provider: provider,
|
|
62
|
-
uid: uid,
|
|
49
|
+
def self.refresh_identity(identity, info:, tokens: {})
|
|
50
|
+
identity.update(
|
|
63
51
|
info: info,
|
|
64
52
|
access_token: tokens[:access_token],
|
|
65
53
|
refresh_token: tokens[:refresh_token],
|
|
66
54
|
expires_at: tokens[:expires_at]
|
|
67
55
|
)
|
|
56
|
+
identity
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.create_user_from_oauth(provider:, uid:, info:, tokens: {}, user_class: nil)
|
|
60
|
+
user_class ||= Spree.user_class
|
|
61
|
+
|
|
62
|
+
transaction do
|
|
63
|
+
user = user_class.create!(
|
|
64
|
+
email: info[:email] || generate_temp_email(provider, uid),
|
|
65
|
+
password: SecureRandom.hex(32), # Random password for OAuth users
|
|
66
|
+
first_name: info[:first_name],
|
|
67
|
+
last_name: info[:last_name]
|
|
68
|
+
)
|
|
68
69
|
|
|
69
|
-
|
|
70
|
+
user.identities.create!(
|
|
71
|
+
provider: provider,
|
|
72
|
+
uid: uid,
|
|
73
|
+
info: info,
|
|
74
|
+
access_token: tokens[:access_token],
|
|
75
|
+
refresh_token: tokens[:refresh_token],
|
|
76
|
+
expires_at: tokens[:expires_at]
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
user
|
|
80
|
+
end
|
|
70
81
|
end
|
|
71
82
|
|
|
72
83
|
def self.generate_temp_email(provider, uid)
|
data/app/models/spree/variant.rb
CHANGED
|
@@ -13,7 +13,7 @@ module Spree
|
|
|
13
13
|
|
|
14
14
|
publishes_lifecycle_events
|
|
15
15
|
|
|
16
|
-
MEMOIZED_METHODS = %w(
|
|
16
|
+
MEMOIZED_METHODS = %w(in_stock on_sale backorderable tax_category options_text compare_at_price)
|
|
17
17
|
|
|
18
18
|
DIMENSION_UNITS = %w[mm cm in ft]
|
|
19
19
|
WEIGHT_UNITS = %w[g kg lb oz]
|
|
@@ -88,6 +88,8 @@ module Spree
|
|
|
88
88
|
validates :dimensions_unit, inclusion: { in: DIMENSION_UNITS }, allow_blank: true
|
|
89
89
|
validates :weight_unit, inclusion: { in: WEIGHT_UNITS }, allow_blank: true
|
|
90
90
|
|
|
91
|
+
validates :backorder_limit, numericality: { only_integer: true, greater_than_or_equal_to: 0, allow_nil: true }
|
|
92
|
+
|
|
91
93
|
after_create :create_stock_items
|
|
92
94
|
after_create :set_master_out_of_stock, unless: :is_master?
|
|
93
95
|
after_commit :clear_line_items_cache, on: :update
|
|
@@ -238,6 +240,19 @@ module Spree
|
|
|
238
240
|
!discontinued? && product.available?
|
|
239
241
|
end
|
|
240
242
|
|
|
243
|
+
# Returns true if the variant is sold as a pre-order: the product is active
|
|
244
|
+
# and not deleted, the variant is flagged preorderable and not discontinued,
|
|
245
|
+
# and the "ships by" date is open-ended or still in the future.
|
|
246
|
+
# Pre-order relaxes only the publish-date embargo, not the rest of the
|
|
247
|
+
# availability rules. Like backorder, a pre-order can make the variant
|
|
248
|
+
# purchasable beyond on-hand stock; the oversell cap is +backorder_limit+
|
|
249
|
+
# (empty ⇒ unlimited), and it adds the ship-by promise.
|
|
250
|
+
# @return [Boolean] true if the variant is a pre-order
|
|
251
|
+
def preorder?
|
|
252
|
+
!discontinued? && preorderable? && product.active? && !product.deleted? &&
|
|
253
|
+
(preorder_ships_at.nil? || preorder_ships_at > Time.current)
|
|
254
|
+
end
|
|
255
|
+
|
|
241
256
|
# Returns true if the variant is in stock or backorderable.
|
|
242
257
|
# @return [Boolean] true if the variant is in stock or backorderable
|
|
243
258
|
def in_stock_or_backorderable?
|
|
@@ -647,7 +662,19 @@ module Spree
|
|
|
647
662
|
alias is_backorderable? backorderable?
|
|
648
663
|
|
|
649
664
|
def purchasable?
|
|
650
|
-
|
|
665
|
+
in_stock? || oversellable_now?
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
# Whether the variant can currently be bought by overselling — via
|
|
669
|
+
# backorder or pre-order. Unlimited when +backorder_limit+ is nil (empty =
|
|
670
|
+
# no cap); with a limit, purchasable only while the oversell allowance
|
|
671
|
+
# remains.
|
|
672
|
+
#
|
|
673
|
+
# @return [Boolean]
|
|
674
|
+
def oversellable_now?
|
|
675
|
+
return false unless backorderable? || preorder?
|
|
676
|
+
|
|
677
|
+
backorder_limit.nil? || can_supply?(1)
|
|
651
678
|
end
|
|
652
679
|
|
|
653
680
|
# Shortcut method to determine if inventory tracking is enabled for this variant
|
|
@@ -58,6 +58,9 @@ module Spree
|
|
|
58
58
|
status: product.status,
|
|
59
59
|
sku: product.sku,
|
|
60
60
|
in_stock: product.in_stock?,
|
|
61
|
+
# True when the product has an active pre-order variant, so a
|
|
62
|
+
# scheduled (future-published) launch still surfaces in search.
|
|
63
|
+
preorder: product.preorder?,
|
|
61
64
|
store_ids: Array(product.store_id).map(&:to_s),
|
|
62
65
|
channel_ids: channel_ids_for_store,
|
|
63
66
|
discontinue_on: product.discontinue_on&.to_i || 0,
|
|
@@ -10,6 +10,9 @@ module Spree
|
|
|
10
10
|
def call(cart:)
|
|
11
11
|
return success(cart) if cart.completed?
|
|
12
12
|
return failure(cart, 'Order is canceled') if cart.canceled?
|
|
13
|
+
# Enforced here (not only in the controller) so every completion path —
|
|
14
|
+
# API, payment-session webhook — honors the channel's guest-checkout gate.
|
|
15
|
+
return failure(cart, Spree.t(:guest_checkout_not_allowed)) if cart.guest_checkout_disallowed?
|
|
13
16
|
|
|
14
17
|
cart.with_lock do
|
|
15
18
|
process_payments!(cart) if cart.payment_required?
|