spree_core 5.0.6 → 5.1.0.beta

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.
Files changed (94) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/images/logo.png +0 -0
  3. data/app/finders/spree/products/find.rb +28 -1
  4. data/app/finders/spree/taxons/find.rb +1 -1
  5. data/app/helpers/spree/base_helper.rb +1 -6
  6. data/app/helpers/spree/images_helper.rb +12 -13
  7. data/app/helpers/spree/integrations_helper.rb +15 -0
  8. data/app/helpers/spree/mail_helper.rb +3 -4
  9. data/app/javascript/spree/core/controllers/disable_submit_button_controller.js +19 -0
  10. data/app/mailers/spree/invitation_mailer.rb +24 -0
  11. data/app/models/concerns/spree/integrations_concern.rb +11 -0
  12. data/app/models/concerns/spree/product_scopes.rb +6 -6
  13. data/app/models/concerns/spree/translatable_resource.rb +4 -0
  14. data/app/models/concerns/spree/translatable_resource_scopes.rb +17 -3
  15. data/app/models/concerns/spree/unique_name.rb +2 -0
  16. data/app/models/concerns/spree/user_management.rb +33 -0
  17. data/app/models/concerns/spree/user_methods.rb +19 -0
  18. data/app/models/concerns/spree/user_roles.rb +43 -17
  19. data/app/models/spree/ability.rb +8 -6
  20. data/app/models/spree/asset.rb +1 -6
  21. data/app/models/spree/base.rb +1 -0
  22. data/app/models/spree/base_analytics_event_handler.rb +7 -2
  23. data/app/models/spree/classification.rb +13 -0
  24. data/app/models/spree/credit_card.rb +24 -2
  25. data/app/models/spree/custom_domain.rb +3 -2
  26. data/app/models/spree/export.rb +1 -1
  27. data/app/models/spree/integration.rb +63 -0
  28. data/app/models/spree/invitation.rb +153 -0
  29. data/app/models/spree/invitations/store.rb +6 -0
  30. data/app/models/spree/option_value.rb +2 -2
  31. data/app/models/spree/order.rb +16 -5
  32. data/app/models/spree/order_merger.rb +7 -5
  33. data/app/models/spree/page_sections/featured_posts.rb +0 -4
  34. data/app/models/spree/payment.rb +1 -2
  35. data/app/models/spree/payment_source.rb +0 -21
  36. data/app/models/spree/post.rb +0 -1
  37. data/app/models/spree/product.rb +75 -5
  38. data/app/models/spree/product_property.rb +1 -1
  39. data/app/models/spree/promotion/rules/item_total.rb +12 -12
  40. data/app/models/spree/property.rb +3 -1
  41. data/app/models/spree/reports/sales_total.rb +5 -1
  42. data/app/models/spree/role.rb +16 -0
  43. data/app/models/spree/role_user.rb +32 -1
  44. data/app/models/spree/shipment_handler.rb +1 -0
  45. data/app/models/spree/shipping_method.rb +2 -2
  46. data/app/models/spree/store.rb +9 -4
  47. data/app/models/spree/store_credit_category.rb +4 -0
  48. data/app/models/spree/taxon.rb +4 -7
  49. data/app/models/spree/theme.rb +1 -1
  50. data/app/models/spree/wishlist.rb +0 -7
  51. data/app/services/spree/country_to_timezone.rb +273 -0
  52. data/app/services/spree/products/prepare_nested_attributes.rb +2 -9
  53. data/app/services/spree/seeds/admin_user.rb +4 -2
  54. data/app/services/spree/seeds/all.rb +3 -1
  55. data/app/services/spree/seeds/digital_delivery.rb +20 -0
  56. data/app/services/spree/seeds/returns_environment.rb +27 -0
  57. data/app/services/spree/seeds/tax_categories.rb +12 -0
  58. data/app/services/spree/stores/settings_defaults_by_country.rb +38 -0
  59. data/app/services/spree/tags/bulk_add.rb +13 -7
  60. data/app/views/spree/addresses/_form.html.erb +1 -1
  61. data/app/views/spree/invitation_mailer/invitation_accepted.html.erb +12 -0
  62. data/app/views/spree/invitation_mailer/invitation_email.html.erb +21 -0
  63. data/app/views/spree/shared/_payment.html.erb +0 -9
  64. data/config/locales/en.yml +48 -20
  65. data/db/migrate/20250407085228_create_spree_integrations.rb +12 -0
  66. data/db/migrate/20250410061306_create_spree_invitations.rb +20 -0
  67. data/db/migrate/20250418174652_add_resource_to_spree_role_users.rb +8 -0
  68. data/db/migrate/20250508060800_add_selected_locale_to_spree_admin_users.rb +8 -0
  69. data/db/migrate/20250509143831_add_session_id_to_spree_assets.rb +5 -0
  70. data/lib/generators/spree/authentication/devise/templates/authentication_helpers.rb.tt +3 -3
  71. data/lib/spree/core/controller_helpers/auth.rb +15 -14
  72. data/lib/spree/core/controller_helpers/currency.rb +11 -0
  73. data/lib/spree/core/controller_helpers/strong_parameters.rb +3 -2
  74. data/lib/spree/core/engine.rb +13 -3
  75. data/lib/spree/core/version.rb +1 -1
  76. data/lib/spree/core.rb +1 -0
  77. data/lib/spree/permitted_attributes.rb +111 -13
  78. data/lib/spree/testing_support/common_rake.rb +7 -25
  79. data/lib/spree/testing_support/extension_rake.rb +1 -1
  80. data/lib/spree/testing_support/factories/integration_factory.rb +7 -0
  81. data/lib/spree/testing_support/factories/invitation_factory.rb +6 -0
  82. data/lib/spree/testing_support/factories/page_section_factory.rb +0 -4
  83. data/lib/spree/testing_support/factories/payment_factory.rb +0 -5
  84. data/lib/spree/testing_support/factories/payment_method_factory.rb +0 -5
  85. data/lib/spree/testing_support/factories/promotion_action_factory.rb +4 -0
  86. data/lib/spree/testing_support/factories/taxon_factory.rb +0 -6
  87. data/lib/spree/testing_support/factories/user_factory.rb +14 -1
  88. data/lib/spree/translation_migrations.rb +27 -15
  89. data/lib/tasks/core.rake +8 -0
  90. metadata +41 -8
  91. data/app/models/concerns/spree/payment_source_concern.rb +0 -39
  92. data/app/models/spree/gateway/custom_payment_source_method.rb +0 -33
  93. data/app/models/spree/product/slugs.rb +0 -112
  94. data/lib/spree/testing_support/factories/payment_source_factory.rb +0 -5
@@ -2,8 +2,6 @@ module Spree
2
2
  class CreditCard < Spree.base_class
3
3
  include ActiveMerchant::Billing::CreditCardMethods
4
4
  include Spree::Metadata
5
- include Spree::PaymentSourceConcern
6
-
7
5
  if defined?(Spree::Webhooks::HasWebhooks)
8
6
  include Spree::Webhooks::HasWebhooks
9
7
  end
@@ -137,6 +135,30 @@ module Spree
137
135
  brand.present? ? brand.upcase : Spree.t(:no_cc_type)
138
136
  end
139
137
 
138
+ def actions
139
+ %w{capture void credit}
140
+ end
141
+
142
+ # Indicates whether its possible to capture the payment
143
+ def can_capture?(payment)
144
+ payment.pending? || payment.checkout?
145
+ end
146
+
147
+ # Indicates whether its possible to void the payment.
148
+ def can_void?(payment)
149
+ !payment.failed? && !payment.void?
150
+ end
151
+
152
+ # Indicates whether its possible to credit the payment. Note that most gateways require that the
153
+ # payment be settled first which generally happens within 12-24 hours of the transaction.
154
+ def can_credit?(payment)
155
+ payment.completed? && payment.credit_allowed > 0
156
+ end
157
+
158
+ def has_payment_profile?
159
+ gateway_customer_profile_id.present? || gateway_payment_profile_id.present?
160
+ end
161
+
140
162
  # ActiveMerchant needs first_name/last_name because we pass it a Spree::CreditCard and it calls those methods on it.
141
163
  # Looking at the ActiveMerchant source code we should probably be calling #to_active_merchant before passing
142
164
  # the object to ActiveMerchant but this should do for now.
@@ -14,7 +14,7 @@ module Spree
14
14
  # Validations
15
15
  #
16
16
  validates :url, presence: true, uniqueness: true, format: {
17
- with: %r{[a-z][a-z0-9-]*[a-z0-9]}i
17
+ with: %r{\A(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z}i
18
18
  }, length: { in: 1..63 }
19
19
  validate :url_is_valid
20
20
 
@@ -26,9 +26,10 @@ module Spree
26
26
  after_validation :ensure_default, on: :create
27
27
 
28
28
  def url_is_valid
29
+ return if url.blank?
29
30
  parts = url.split('.')
30
31
 
31
- errors.add(:url, 'use domain or subdomain') if parts.size > 4 || parts.size < 2
32
+ errors.add(:url, 'use domain or subdomain') if (parts[0] != 'www' && parts.size > 3) || (parts[0] == 'www' && parts.size > 4) || parts.size < 2
32
33
  end
33
34
 
34
35
  def ensure_default
@@ -124,7 +124,7 @@ module Spree
124
124
  end
125
125
 
126
126
  def current_ability
127
- @current_ability ||= Spree::Dependencies.ability_class.constantize.new(user)
127
+ @current_ability ||= Spree::Dependencies.ability_class.constantize.new(user, { store: store })
128
128
  end
129
129
 
130
130
  # eg. Spree::Exports::Products => products-store-my-store-code-20241030133348.csv
@@ -0,0 +1,63 @@
1
+ module Spree
2
+ class Integration < Spree.base_class
3
+ include Spree::SingleStoreResource
4
+
5
+ #
6
+ # Associations
7
+ #
8
+ belongs_to :store, class_name: 'Spree::Store', touch: true
9
+
10
+ #
11
+ # Validations
12
+ #
13
+ validates :type, presence: true
14
+ validates :store, presence: true, uniqueness: { scope: :type }
15
+
16
+ #
17
+ # Scopes
18
+ #
19
+ scope :active, -> { where(active: true) }
20
+
21
+ # This attribute is used to temporarily store connection-related error messages
22
+ # that can be displayed to users when testing or validating integration connections.
23
+ # It is not persisted to the database and is reset on each new connection attempt.
24
+ # @param message [String, nil] The error message to be stored
25
+ # @return [String, nil] The current error message
26
+ attr_accessor :connection_error_message
27
+
28
+ # Associates the integration to a group.
29
+ # The name here will be used as Spree.t key to display the group name.
30
+ # Leave blank to leave the integration ungrouped.
31
+ def self.integration_group
32
+ nil
33
+ end
34
+
35
+ def self.icon_path
36
+ nil
37
+ end
38
+
39
+ def self.integration_name
40
+ name.demodulize.titleize.strip
41
+ end
42
+
43
+ def self.integration_key
44
+ name.demodulize.underscore
45
+ end
46
+
47
+ def name
48
+ self.class.integration_name
49
+ end
50
+
51
+ def key
52
+ self.class.integration_key
53
+ end
54
+
55
+ # Checks if the integration can establish a connection.
56
+ # This is a base implementation that always returns true.
57
+ # Subclasses should override this method to implement their own connection validation logic.
58
+ # @return [Boolean] true if the integration can connect, false otherwise
59
+ def can_connect?
60
+ true
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,153 @@
1
+ module Spree
2
+ class Invitation < Spree.base_class
3
+ has_secure_token
4
+ acts_as_paranoid
5
+
6
+ #
7
+ # Virtual Attributes
8
+ #
9
+ attribute :skip_email, :boolean, default: false
10
+
11
+ #
12
+ # Associations
13
+ #
14
+ belongs_to :resource, polymorphic: true # eg. Store, Vendor, Account
15
+ belongs_to :inviter, polymorphic: true # User or AdminUser
16
+ belongs_to :invitee, polymorphic: true, optional: true # User or AdminUser
17
+ belongs_to :role, class_name: 'Spree::Role'
18
+ has_one :role_user, dependent: :destroy, class_name: 'Spree::RoleUser'
19
+
20
+ #
21
+ # Validations
22
+ #
23
+ validates :email, email: true, presence: true
24
+ validates :token, presence: true, uniqueness: true
25
+ validates :inviter, :resource, :role, presence: true
26
+ validate :invitee_is_not_inviter, on: :create
27
+ validate :invitee_already_exists, on: :create
28
+
29
+ #
30
+ # Scopes
31
+ #
32
+ scope :pending, -> { where(status: 'pending') }
33
+ scope :accepted, -> { where(status: 'accepted') }
34
+ scope :not_expired, -> { where('expires_at > ?', Time.current) }
35
+
36
+ #
37
+ # State Machine
38
+ #
39
+ state_machine initial: :pending, attribute: :status do
40
+ state :accepted do
41
+ validate :accept_invitation_within_time_limit
42
+ validates :invitee, presence: true
43
+ end
44
+
45
+ event :accept do
46
+ transition pending: :accepted
47
+ end
48
+ after_transition to: :accepted, do: :after_accept
49
+ end
50
+
51
+ #
52
+ # Callbacks
53
+ #
54
+ after_initialize :set_defaults, if: :new_record?
55
+ before_validation :set_invitee_from_email, on: :create
56
+ after_create :send_invitation_email, unless: :skip_email
57
+
58
+ # returns the store for the invitation
59
+ # if the resource is a store, return the resource
60
+ # if the resource responds to store, return the store
61
+ # otherwise, return the current store
62
+ # @return [Spree::Store]
63
+ def store
64
+ if resource.is_a?(Spree::Store)
65
+ resource
66
+ elsif resource.respond_to?(:store)
67
+ resource.store
68
+ else
69
+ Spree::Store.current
70
+ end
71
+ end
72
+
73
+ # returns true if the invitation has expired
74
+ # @return [Boolean]
75
+ def expired?
76
+ expires_at < Time.current
77
+ end
78
+
79
+ # Resends the invitation email if the invitation is pending and not expired
80
+ def resend!
81
+ return if expired? || deleted? || accepted?
82
+
83
+ send_invitation_email
84
+ end
85
+
86
+ private
87
+
88
+ # this method can be extended by developers now
89
+ def after_accept
90
+ create_role_user
91
+ set_accepted_at
92
+ send_acceptance_notification
93
+ end
94
+
95
+ def send_invitation_email
96
+ Spree::InvitationMailer.invitation_email(self).deliver_later
97
+ end
98
+
99
+ def send_acceptance_notification
100
+ Spree::InvitationMailer.invitation_accepted(self).deliver_later
101
+ end
102
+
103
+ def set_defaults
104
+ self.expires_at ||= 2.weeks.from_now
105
+ self.resource ||= Spree::Store.current
106
+ self.role ||= Spree::Role.default_admin_role
107
+ end
108
+
109
+ def invitee_is_not_inviter
110
+ if invitee == inviter
111
+ errors.add(:invitee, 'cannot be the same as the inviter')
112
+ end
113
+ end
114
+
115
+ def invitee_already_exists
116
+ return if resource.blank?
117
+
118
+ exists = if invitee.present?
119
+ resource.users.include?(invitee)
120
+ else
121
+ resource.users.exists?(email: email)
122
+ end
123
+
124
+ if exists
125
+ errors.add(:email, 'already exists')
126
+ end
127
+ end
128
+
129
+ def set_accepted_at
130
+ update!(accepted_at: Time.current)
131
+ end
132
+
133
+ def create_role_user
134
+ return if invitee.blank?
135
+
136
+ role_user = resource.add_user(invitee, role)
137
+ self.role_user = role_user
138
+ save!
139
+ end
140
+
141
+ def set_invitee_from_email
142
+ return if invitee.present?
143
+
144
+ self.invitee = Spree.admin_user_class.find_by(email: email)
145
+ end
146
+
147
+ def accept_invitation_within_time_limit
148
+ if Time.current > expires_at
149
+ errors.add(:base, 'Invitation expired')
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,6 @@
1
+ module Spree
2
+ module Invitations
3
+ class Store < Base
4
+ end
5
+ end
6
+ end
@@ -62,9 +62,9 @@ module Spree
62
62
  delegate :name, :presentation, to: :option_type, prefix: true, allow_nil: true
63
63
 
64
64
  def self.to_tom_select_json
65
- all.pluck(:name, :presentation).map do |name, presentation|
65
+ all.pluck(:id, :presentation).map do |id, presentation|
66
66
  {
67
- id: name,
67
+ id: id,
68
68
  name: presentation
69
69
  }
70
70
  end
@@ -91,6 +91,8 @@ module Spree
91
91
  acts_as_taggable_on :tags
92
92
  acts_as_taggable_tenant :store_id
93
93
 
94
+ ASSOCIATED_USER_ATTRIBUTES = [:user_id, :email, :created_by_id, :bill_address_id, :ship_address_id]
95
+
94
96
  belongs_to :user, class_name: "::#{Spree.user_class}", optional: true, autosave: true
95
97
  belongs_to :created_by, class_name: "::#{Spree.admin_user_class}", optional: true
96
98
  belongs_to :approver, class_name: "::#{Spree.admin_user_class}", optional: true
@@ -247,6 +249,13 @@ module Spree
247
249
  pre_tax_item_amount + shipments.sum(:pre_tax_amount)
248
250
  end
249
251
 
252
+ # Returns the subtotal used for analytics integrations
253
+ # It's a sum of the item total and the promo total
254
+ # @return [Float]
255
+ def analytics_subtotal
256
+ (item_total + line_items.sum(:promo_total)).to_f
257
+ end
258
+
250
259
  def shipping_discount
251
260
  shipment_adjustments.non_tax.eligible.sum(:amount) * - 1
252
261
  end
@@ -355,7 +364,7 @@ module Spree
355
364
  self.bill_address ||= user.bill_address
356
365
  self.ship_address ||= user.ship_address
357
366
 
358
- changes = slice(:user_id, :email, :created_by_id, :bill_address_id, :ship_address_id)
367
+ changes = slice(*ASSOCIATED_USER_ATTRIBUTES)
359
368
 
360
369
  # immediately persist the changes we just made, but don't use save
361
370
  # since we might have an invalid address associated
@@ -364,6 +373,12 @@ module Spree
364
373
  end
365
374
  end
366
375
 
376
+ def disassociate_user!
377
+ nullified_attributes = ASSOCIATED_USER_ATTRIBUTES.index_with(nil)
378
+
379
+ update!(nullified_attributes)
380
+ end
381
+
367
382
  def quantity_of(variant, options = {})
368
383
  line_item = find_line_item_by_variant(variant, options)
369
384
  line_item ? line_item.quantity : 0
@@ -815,10 +830,6 @@ module Spree
815
830
  csv_lines
816
831
  end
817
832
 
818
- def all_line_items
819
- line_items
820
- end
821
-
822
833
  private
823
834
 
824
835
  def link_by_email
@@ -7,7 +7,7 @@ module Spree
7
7
  @order = order
8
8
  end
9
9
 
10
- def merge!(other_order, user = nil)
10
+ def merge!(other_order, user = nil, discard_merged: true)
11
11
  other_order.line_items.each do |other_order_line_item|
12
12
  next unless other_order_line_item.currency == order.currency
13
13
 
@@ -16,12 +16,14 @@ module Spree
16
16
  end
17
17
 
18
18
  set_user(user)
19
- clear_addresses(other_order)
19
+ clear_addresses(other_order) if discard_merged
20
20
  persist_merge
21
21
 
22
- # So that the destroy doesn't take out line items which may have been re-assigned
23
- other_order.line_items.reload
24
- other_order.destroy
22
+ if discard_merged
23
+ # So that the destroy doesn't take out line items which may have been re-assigned
24
+ other_order.line_items.reload
25
+ other_order.destroy
26
+ end
25
27
  end
26
28
 
27
29
  # Compare the line item of the other order with mine.
@@ -19,10 +19,6 @@ module Spree
19
19
  'news'
20
20
  end
21
21
 
22
- def posts
23
- Spree::Post.published.by_newest.limit(preferred_max_posts_to_show)
24
- end
25
-
26
22
  private
27
23
 
28
24
  def make_posts_to_show_valid
@@ -302,8 +302,7 @@ module Spree
302
302
  # Payment profile cannot be created without source
303
303
  return unless source
304
304
  # Imported payments shouldn't create a payment profile.
305
- # Imported is only available on Spree::CreditCard, non-credit card payments should not have this attribute.
306
- return if source.respond_to?(:imported) && source.imported
305
+ return if source.imported
307
306
 
308
307
  payment_method.create_profile(self)
309
308
  rescue ActiveMerchant::ConnectionError => e
@@ -1,31 +1,10 @@
1
- # This model is used to store payment sources for non-credit card payments, eg wallet, account, etc.
2
1
  module Spree
3
2
  class PaymentSource < Spree.base_class
4
3
  include Spree::Metadata
5
- include Spree::PaymentSourceConcern
6
4
 
7
- #
8
- # Associations
9
- #
10
5
  belongs_to :payment_method, class_name: 'Spree::PaymentMethod'
11
6
  belongs_to :user, class_name: Spree.user_class.to_s, optional: true
12
7
 
13
- #
14
- # Validations
15
- #
16
8
  validates_uniqueness_of :gateway_payment_profile_id, scope: :type
17
-
18
- #
19
- # Delegations
20
- #
21
- delegate :profile_id, to: :gateway_customer, prefix: true, allow_nil: true
22
-
23
- # Returns the gateway customer for the user.
24
- # @return [Spree::GatewayCustomer]
25
- def gateway_customer
26
- return if user.blank?
27
-
28
- payment_method.gateway_customers.find_by(user: user)
29
- end
30
9
  end
31
10
  end
@@ -54,7 +54,6 @@ module Spree
54
54
  # Scopes
55
55
  #
56
56
  scope :published, -> { where(published_at: [..Time.current]) }
57
- scope :by_newest, -> { order(created_at: :desc) }
58
57
 
59
58
  delegate :name, to: :author, prefix: true, allow_nil: true
60
59
  delegate :title, to: :post_category, prefix: true, allow_nil: true
@@ -20,17 +20,14 @@
20
20
 
21
21
  module Spree
22
22
  class Product < Spree.base_class
23
- acts_as_paranoid
24
- acts_as_taggable_on :tags, :labels
25
- auto_strip_attributes :name
26
-
23
+ extend FriendlyId
27
24
  include Spree::ProductScopes
28
25
  include Spree::MultiStoreResource
29
26
  include Spree::TranslatableResource
27
+ include Spree::TranslatableResourceSlug
30
28
  include Spree::MemoizedData
31
29
  include Spree::Metadata
32
30
  include Spree::Product::Webhooks
33
- include Spree::Product::Slugs
34
31
  if defined?(Spree::VendorConcern)
35
32
  include Spree::VendorConcern
36
33
  end
@@ -56,8 +53,33 @@ module Spree
56
53
  pg_search_scope :search_by_name, against: { name: 'A', meta_title: 'B' },
57
54
  using: { tsearch: { prefix: true, any_word: true } }
58
55
  end
56
+
57
+ before_save :set_slug
58
+ acts_as_paranoid
59
+ # deleted translation values also need to be accessible for index views listing deleted resources
60
+ default_scope { unscope(where: :deleted_at) }
61
+ def set_slug
62
+ self.slug = generate_slug
63
+ end
64
+
65
+ private
66
+
67
+ def generate_slug
68
+ if name.blank? && slug.blank?
69
+ translated_model.name.to_url
70
+ elsif slug.blank?
71
+ name.to_url
72
+ else
73
+ slug.to_url
74
+ end
75
+ end
59
76
  end
60
77
 
78
+ friendly_id :slug_candidates, use: [:history, :scoped, :mobility], scope: spree_base_uniqueness_scope
79
+ acts_as_paranoid
80
+ auto_strip_attributes :name
81
+ acts_as_taggable_on :tags, :labels
82
+
61
83
  # we need to have this callback before any dependent: :destroy associations
62
84
  # https://github.com/rails/rails/issues/3458
63
85
  before_destroy :ensure_not_in_complete_orders
@@ -123,12 +145,17 @@ module Spree
123
145
  after_initialize :ensure_master
124
146
  after_initialize :assign_default_tax_category
125
147
 
148
+ before_validation :downcase_slug
149
+ before_validation :normalize_slug, on: :update
126
150
  before_validation :validate_master
127
151
  before_validation :ensure_default_shipping_category
128
152
 
129
153
  after_create :add_associations_from_prototype
130
154
  after_create :build_variants_from_option_values_hash, if: :option_values_hash
131
155
 
156
+ after_destroy :punch_slug
157
+ after_restore :update_slug_history
158
+
132
159
  after_save :save_master
133
160
  after_save :run_touch_callbacks, if: :anything_changed?
134
161
  after_save :reset_nested_changes
@@ -146,6 +173,7 @@ module Spree
146
173
  validates :price, if: :requires_price?
147
174
  end
148
175
 
176
+ validates :slug, presence: true, uniqueness: { allow_blank: true, case_sensitive: true, scope: spree_base_uniqueness_scope }
149
177
  validate :discontinue_on_must_be_later_than_make_active_at, if: -> { make_active_at && discontinue_on }
150
178
 
151
179
  scope :for_store, ->(store) { joins(:store_products).where(StoreProduct.table_name => { store_id: store.id }) }
@@ -484,6 +512,17 @@ module Spree
484
512
  where conditions.inject(:or)
485
513
  end
486
514
 
515
+ def self.slug_available?(slug, id)
516
+ !where(slug: slug).where.not(id: id).exists?
517
+ end
518
+
519
+ def ensure_slug_is_unique(candidate_slug)
520
+ return slug if candidate_slug.blank? || slug.blank?
521
+ return candidate_slug if self.class.slug_available?(candidate_slug, id)
522
+
523
+ normalize_friendly_id([candidate_slug, uuid_for_friendly_id])
524
+ end
525
+
487
526
  # Suitable for displaying only variants that has at least one option value.
488
527
  # There may be scenarios where an option type is removed and along with it
489
528
  # all option values. At that point all variants associated with only those
@@ -699,6 +738,25 @@ module Spree
699
738
  self.tax_category = Spree::TaxCategory.default if new_record?
700
739
  end
701
740
 
741
+ def normalize_slug
742
+ self.slug = normalize_friendly_id(slug)
743
+ end
744
+
745
+ def punch_slug
746
+ # punch slug with date prefix to allow reuse of original
747
+ return if frozen?
748
+
749
+ update_column(:slug, "#{Time.current.to_i}_#{slug}"[0..254])
750
+
751
+ translations.with_deleted.each do |t|
752
+ t.update_column :slug, "#{Time.current.to_i}_#{t.slug}"[0..254]
753
+ end
754
+ end
755
+
756
+ def update_slug_history
757
+ save!
758
+ end
759
+
702
760
  def anything_changed?
703
761
  saved_changes? || @nested_changes
704
762
  end
@@ -754,6 +812,14 @@ module Spree
754
812
  end
755
813
  end
756
814
 
815
+ # Try building a slug based on the following fields in increasing order of specificity.
816
+ def slug_candidates
817
+ [
818
+ :name,
819
+ [:name, :sku]
820
+ ]
821
+ end
822
+
757
823
  def run_touch_callbacks
758
824
  run_callbacks(:touch)
759
825
  end
@@ -801,6 +867,10 @@ module Spree
801
867
  previously_new_record? || tag_list_previously_changed? || available_on_previously_changed?
802
868
  end
803
869
 
870
+ def downcase_slug
871
+ slug&.downcase!
872
+ end
873
+
804
874
  def after_activate
805
875
  # Implement your logic here
806
876
  end
@@ -33,7 +33,7 @@ module Spree
33
33
  scope :filterable, -> { joins(:property).where(Property.table_name => { filterable: true }) }
34
34
  scope :for_products, ->(products) { joins(:product).merge(products) }
35
35
  scope :sort_by_property_position, -> {
36
- joins(:property).order("spree_properties.position ASC")
36
+ unscope(:order).joins(:property).order("spree_properties.position ASC")
37
37
  }
38
38
 
39
39
  self.whitelisted_ransackable_attributes = ['value', 'filter_param']
@@ -27,39 +27,39 @@ module Spree
27
27
  upper_limit_condition = true
28
28
  end
29
29
 
30
- eligibility_errors.add(:base, ineligible_message_max(order)) unless upper_limit_condition
31
- eligibility_errors.add(:base, ineligible_message_min(order)) unless lower_limit_condition
30
+ eligibility_errors.add(:base, ineligible_message_max) unless upper_limit_condition
31
+ eligibility_errors.add(:base, ineligible_message_min) unless lower_limit_condition
32
32
 
33
33
  eligibility_errors.empty?
34
34
  end
35
35
 
36
36
  private
37
37
 
38
- def formatted_amount_min(order)
39
- Spree::Money.new(preferred_amount_min, currency: order.currency).to_s
38
+ def formatted_amount_min
39
+ Spree::Money.new(preferred_amount_min).to_s
40
40
  end
41
41
 
42
- def formatted_amount_max(order)
42
+ def formatted_amount_max
43
43
  if preferred_amount_max.present?
44
- Spree::Money.new(preferred_amount_max, currency: order.currency).to_s
44
+ Spree::Money.new(preferred_amount_max).to_s
45
45
  else
46
46
  Spree.t('no_maximum')
47
47
  end
48
48
  end
49
49
 
50
- def ineligible_message_max(order)
50
+ def ineligible_message_max
51
51
  if preferred_operator_max == 'lt'
52
- eligibility_error_message(:item_total_more_than_or_equal, amount: formatted_amount_max(order))
52
+ eligibility_error_message(:item_total_more_than_or_equal, amount: formatted_amount_max)
53
53
  else
54
- eligibility_error_message(:item_total_more_than, amount: formatted_amount_max(order))
54
+ eligibility_error_message(:item_total_more_than, amount: formatted_amount_max)
55
55
  end
56
56
  end
57
57
 
58
- def ineligible_message_min(order)
58
+ def ineligible_message_min
59
59
  if preferred_operator_min == 'gte'
60
- eligibility_error_message(:item_total_less_than, amount: formatted_amount_min(order))
60
+ eligibility_error_message(:item_total_less_than, amount: formatted_amount_min)
61
61
  else
62
- eligibility_error_message(:item_total_less_than_or_equal, amount: formatted_amount_min(order))
62
+ eligibility_error_message(:item_total_less_than_or_equal, amount: formatted_amount_min)
63
63
  end
64
64
  end
65
65
  end
@@ -39,8 +39,10 @@ module Spree
39
39
  KIND_OPTIONS = { short_text: 0, long_text: 1, number: 2, rich_text: 3 }.freeze
40
40
  enum :kind, KIND_OPTIONS
41
41
 
42
+ DEPENDENCY_UPDATE_FIELDS = [:presentation, :name, :kind, :filterable, :display_on, :position].freeze
43
+
42
44
  after_touch :touch_all_products
43
- after_update :touch_all_products, if: -> { saved_changes.key?(:presentation) }
45
+ after_update :touch_all_products, if: -> { DEPENDENCY_UPDATE_FIELDS.any? { |field| saved_changes.key?(field) } }
44
46
  after_save :ensure_product_properties_have_filter_params
45
47
 
46
48
  self.whitelisted_ransackable_attributes = ['presentation', 'filterable']