Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/app/controllers/spree/api/base_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ def product_scope
end

def variants_associations
[{ option_values: :option_type }, :default_price, :images]
[{ option_values: :option_type }, :prices, :images]
end

def product_includes
Expand Down
2 changes: 1 addition & 1 deletion api/app/controllers/spree/api/shipments_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ def mine_includes
},
variant: {
product: {},
default_price: {},
prices: {},
option_values: {
option_type: {}
}
Expand Down
2 changes: 1 addition & 1 deletion api/app/controllers/spree/api/taxons_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ def products
# Products#index does not do the sorting.
taxon = Spree::Taxon.find(params[:id])
@products = paginate(taxon.products.ransack(params[:q]).result)
@products = @products.includes(master: :default_price)
@products = @products.includes(master: :prices)

if params[:simple]
@exclude_data = {
Expand Down
2 changes: 1 addition & 1 deletion api/app/controllers/spree/api/variants_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ def variant_params
end

def include_list
[{ option_values: :option_type }, :product, :default_price, :images, { stock_items: :stock_location }]
[{ option_values: :option_type }, :product, :prices, :images, { stock_items: :stock_location }]
end
end
end
Expand Down
2 changes: 1 addition & 1 deletion backend/app/controllers/spree/admin/products_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ def update_before
end

def product_includes
[:variant_images, { variants: [:images], master: [:images, :default_price] }]
[:variant_images, { variants: [:images], master: [:images, :prices] }]
end

def clone_object_url(resource)
Expand Down
4 changes: 2 additions & 2 deletions backend/app/controllers/spree/admin/variants_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def new_before
@object.attributes = @object.product.master.attributes.except('id', 'created_at', 'deleted_at',
'sku', 'is_master')
# Shallow Clone of the default price to populate the price field.
@object.default_price = @object.product.master.default_price.clone
@object.prices.build(@object.product.master.default_price.attributes.except("id", "created_at", "updated_at", "deleted_at"))
end

def collection
Expand All @@ -35,7 +35,7 @@ def load_data
end

def variant_includes
[{ option_values: :option_type }, :default_price]
[{ option_values: :option_type }, :prices]
end

def redirect_on_empty_option_values
Expand Down
2 changes: 1 addition & 1 deletion backend/app/views/spree/admin/products/index.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
<%= render 'spree/admin/shared/image', image: product.gallery.images.first, size: :mini %>
</td>
<td><%= link_to product.try(:name), edit_admin_product_path(product) %></td>
<td class="align-right"><%= product.display_price.to_html %></td>
<td class="align-right"><%= product.display_price&.to_html %></td>
Comment thread
waiting-for-dev marked this conversation as resolved.
<td class="actions" data-hook="admin_products_index_row_actions">
<%= link_to_edit product, no_text: true, class: 'edit' if can?(:edit, product) && !product.deleted? %>
&nbsp;
Expand Down
2 changes: 1 addition & 1 deletion backend/app/views/spree/admin/variants/_form.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
<div class="col-3">
<div class="field" data-hook="price">
<%= f.label :price %>
<%= render "spree/admin/shared/number_with_currency", f: f, amount_attr: :price, currency: @variant.find_or_build_default_price.currency %>
<%= render "spree/admin/shared/number_with_currency", f: f, amount_attr: :price, currency: @variant.default_price_or_build.currency %>
</div>
</div>

Expand Down
66 changes: 56 additions & 10 deletions core/app/models/concerns/spree/default_price.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ module DefaultPrice
extend ActiveSupport::Concern

included do
has_one :default_price,
-> { with_discarded.currently_valid.with_default_attributes },
class_name: 'Spree::Price',
inverse_of: :variant,
dependent: :destroy,
autosave: true
delegate :display_price, :display_amount, :price, to: :default_price, allow_nil: true
delegate :price=, to: :default_price_or_build

# @see Spree::Variant::PricingOptions.default_price_attributes
def self.default_price_attributes
Spree::Config.default_pricing_options.desired_attributes
end
end

# Returns `#prices` prioritized for being considered as default price
Expand All @@ -20,15 +21,60 @@ def currently_valid_prices
prices.currently_valid
end

def find_or_build_default_price
default_price || build_default_price(Spree::Config.default_pricing_options.desired_attributes)
# Returns {#default_price} or builds it from {Spree::Variant.default_price_attributes}
#
# @return [Spree::Price, nil]
# @see Spree::Variant.default_price_attributes
def default_price_or_build
default_price ||
prices.build(self.class.default_price_attributes)
end

delegate :display_price, :display_amount, :price, to: :find_or_build_default_price
delegate :price=, to: :find_or_build_default_price
# Select from {#prices} the one to be considered as the default
#
# This method works with the in-memory association, so non-persisted prices
# are taken into account. Discarded prices are also considered.
#
# A price is a candidate to be considered as the default when it meets
# {Spree::Variant.default_price_attributes} criteria. When more than one candidate is
# found, non-persisted records take preference. When more than one persisted
# candidate exists, the one most recently updated is taken or, in case of
# race condition, the one with higher id.
#
# @return [Spree::Price, nil]
# @see Spree::Variant.default_price_attributes
def default_price
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method is a bit complex and hard to read to understand what it does. Any thoughts about moving it to a separate class that is responsible to select the default price only?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I completely agree. I have some reserves because of:

  • It would be nice to have it be injectable for the end-user. However, we haven't decided how to do that yet. We could build on top of the old system, though.
  • The logic around prices is very complex, and I'd like to simplify it further. However, probably it's not a preference at this point.

WDYT? We can do it now if you think it's better to leave it clean at this point.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As we discussed IRL yesterday. Maybe a good tradeoff could be trying to use more semantic variable names and/or extracting something to a method in this same class to help reading the code.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to make everything clear. I think that the result is much more readable. Thanks for pointing it out. Please, take a look when you have a second.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is perfect, thanks Marc!

prioritized_default(
prices_meeting_criteria_to_be_default(
(prices + prices.with_discarded).uniq
)
)
end

def has_default_price?
default_price.present? && !default_price.discarded?
end

private

def prices_meeting_criteria_to_be_default(prices)
criteria = self.class.default_price_attributes.transform_keys(&:to_s)
prices.select do |price|
contender = price.attributes.slice(*criteria.keys)
criteria == contender
end
end

def prioritized_default(prices)
prices.min do |prev, succ|
contender_one, contender_two = [succ, prev].map do |item|
[
item.updated_at || Time.zone.now,
item.id || Float::INFINITY
]
end
contender_one <=> contender_two
end
end
end
end
11 changes: 11 additions & 0 deletions core/app/models/spree/product.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,17 @@ class Product < Spree::Base
has_many :line_items, through: :variants_including_master
has_many :orders, through: :line_items

scope :sort_by_master_default_price_amount_asc, -> {
with_default_price.order('spree_prices.amount ASC')
}
scope :sort_by_master_default_price_amount_desc, -> {
with_default_price.order('spree_prices.amount DESC')
}
scope :with_default_price, -> {
left_joins(master: :prices)
.where(master: { spree_prices: Spree::Config.default_pricing_options.desired_attributes })
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This didn't work with some database adapters in the past. Can you confirm it does with pg, mysql and sqlite? Because if this works, the cursed Spree::Variant.with_prices scope can go.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems it works. That's the SQL generated for each adapter:

sqlite:

=> "SELECT \"spree_products\".* FROM \"spree_products\" LEFT OUTER JOIN \"spree_variants\" \"master\" ON \"master\".\"is_master\" = 1 AND \"master\".\"product_id\" = \"spree_products\".\"id\" LEFT OUTER JOIN \"spree_prices\" ON \"spree_prices\".\"deleted_at\" IS NULL AND \"spree_prices\".\"variant_id\" = \"master\".\"id\" WHERE \"spree_products\".\"deleted_at\" IS NULL AND \"spree_prices\".\"currency\" = 'USD' AND \"spree_prices\".\"country_iso\" IS NULL"

Postgres:

"SELECT \"spree_products\".* FROM \"spree_products\" LEFT OUTER JOIN \"spree_variants\" \"master\" ON \"master\".\"is_master\" = TRUE AND \"master\".\"product_id\" = \"spree_products\".\"id\" LEFT OUTER JOIN \"spree_prices\" ON \"spree_prices\".\"deleted_at\" IS NULL AND \"spree_prices\".\"variant_id\" = \"master\".\"id\" WHERE \"spree_products\".\"deleted_at\" IS NULL AND \"spree_prices\".\"currency\" = 'USD' AND \"spree_prices\".\"country_iso\" IS NULL"

MySQL:

"SELECT `spree_products`.* FROM `spree_products` LEFT OUTER JOIN `spree_variants` `master` ON `master`.`is_master` = TRUE AND `master`.`product_id` = `spree_products`.`id` LEFT OUTER JOIN `spree_prices` ON `spree_prices`.`deleted_at` IS NULL AND `spree_prices`.`variant_id` = `master`.`id` WHERE `spree_products`.`deleted_at` IS NULL AND `spree_prices`.`currency` = 'USD' AND `spree_prices`.`country_iso` IS NULL"

While the tests using that method work in the three adapters:

We could tackle that scope in a separate PR.

}

def find_or_build_master
master || build_master
end
Expand Down
10 changes: 5 additions & 5 deletions core/app/models/spree/product/scopes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,25 +29,25 @@ def self.property_conditions(property)
scope :descend_by_name, -> { order(name: :desc) }

add_search_scope :ascend_by_master_price do
joins(master: :default_price).select('spree_products.* , spree_prices.amount')
joins(master: :prices).select('spree_products.* , spree_prices.amount')
.order(Spree::Price.arel_table[:amount].asc)
end

add_search_scope :descend_by_master_price do
joins(master: :default_price).select('spree_products.* , spree_prices.amount')
joins(master: :prices).select('spree_products.* , spree_prices.amount')
.order(Spree::Price.arel_table[:amount].desc)
end

add_search_scope :price_between do |low, high|
joins(master: :default_price).where(Price.table_name => { amount: low..high })
joins(master: :prices).where(Price.table_name => { amount: low..high })
end

add_search_scope :master_price_lte do |price|
joins(master: :default_price).where("#{price_table_name}.amount <= ?", price)
joins(master: :prices).where("#{price_table_name}.amount <= ?", price)
end

add_search_scope :master_price_gte do |price|
joins(master: :default_price).where("#{price_table_name}.amount >= ?", price)
joins(master: :prices).where("#{price_table_name}.amount >= ?", price)
end

# This scope selects products in taxon AND all its descendants
Expand Down
2 changes: 1 addition & 1 deletion core/lib/spree/core/product_filters.rb
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ module ProductFilters
scope = scope.or(new_scope)
end

Spree::Product.joins(master: :default_price).where(scope)
Spree::Product.joins(master: :prices).where(scope)
end

def self.format_price(amount)
Expand Down
25 changes: 23 additions & 2 deletions core/spec/models/spree/product_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,7 @@ class Extension < Spree::Base

context "with currency set to JPY" do
before do
product.master.default_price.currency = 'JPY'
product.master.default_price.save!
product.master.default_price.update!(currency: 'JPY')
stub_spree_preferences(currency: 'JPY')
end

Expand Down Expand Up @@ -619,4 +618,26 @@ class Extension < Spree::Base
expect(subject).to respond_to(:images)
end
end

describe '.sort_by_master_default_price_amount_asc' do
it 'returns first those which default price is lower' do
product_1 = create(:product, price: 10)
product_2 = create(:product, price: 5)

result = described_class.sort_by_master_default_price_amount_asc

expect(result).to eq([product_2, product_1])
end
end

describe '.sort_by_master_default_price_amount_desc' do
it 'returns first those which default price is higher' do
product_1 = create(:product, price: 10)
product_2 = create(:product, price: 5)

result = described_class.sort_by_master_default_price_amount_desc

expect(result).to eq([product_1, product_2])
end
end
end
Loading