spree_core 5.4.3 → 5.5.0.rc1
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 +0 -82
- data/app/helpers/spree/currency_helper.rb +0 -12
- data/app/helpers/spree/products_helper.rb +0 -8
- data/app/jobs/spree/base_job.rb +18 -0
- data/app/jobs/spree/events/subscriber_job.rb +2 -1
- data/app/jobs/spree/exports/generate_job.rb +11 -0
- data/app/jobs/spree/images/save_from_url_job.rb +23 -8
- data/app/jobs/spree/imports/assign_tags_job.rb +11 -0
- data/app/jobs/spree/imports/base_job.rb +15 -0
- data/app/jobs/spree/imports/create_categories_job.rb +37 -0
- data/app/jobs/spree/imports/create_rows_job.rb +1 -3
- data/app/jobs/spree/imports/process_group_job.rb +8 -6
- data/app/jobs/spree/imports/process_rows_job.rb +1 -3
- data/app/jobs/spree/media/migrate_product_assets_job.rb +83 -0
- data/app/jobs/spree/products/refresh_metrics_job.rb +15 -4
- data/app/jobs/spree/reports/generate_job.rb +11 -0
- data/app/jobs/spree/search_provider/index_job.rb +5 -1
- data/app/jobs/spree/search_provider/remove_job.rb +4 -0
- data/app/jobs/spree/stock_reservations/expire_job.rb +11 -0
- data/app/models/concerns/spree/calculated_adjustments.rb +34 -1
- data/app/models/concerns/spree/display_on.rb +31 -0
- data/app/models/concerns/spree/metafields.rb +167 -5
- data/app/models/concerns/spree/preference_schema.rb +191 -0
- data/app/models/concerns/spree/prefixed_id.rb +94 -11
- data/app/models/concerns/spree/product_scopes.rb +36 -17
- data/app/models/concerns/spree/ransackable_attributes.rb +5 -1
- data/app/models/concerns/spree/search_indexable.rb +8 -7
- data/app/models/concerns/spree/searchable.rb +11 -2
- data/app/models/concerns/spree/stores/channels.rb +20 -0
- data/app/models/concerns/spree/stores/markets.rb +21 -5
- data/app/models/concerns/spree/typed_associations.rb +120 -0
- data/app/models/concerns/spree/user_methods.rb +71 -12
- data/app/models/spree/ability.rb +4 -117
- data/app/models/spree/api_key.rb +53 -0
- data/app/models/spree/asset.rb +28 -5
- data/app/models/spree/authentication/strategy_registry.rb +72 -0
- data/app/models/spree/base.rb +18 -1
- data/app/models/spree/channel.rb +159 -0
- data/app/models/spree/country.rb +2 -0
- data/app/models/spree/current.rb +5 -1
- data/app/models/spree/custom_field.rb +9 -0
- data/app/models/spree/custom_field_definition.rb +7 -0
- data/app/models/spree/customer_group.rb +8 -2
- data/app/models/spree/export.rb +30 -3
- data/app/models/spree/gateway.rb +25 -0
- data/app/models/spree/gift_card.rb +1 -1
- data/app/models/spree/gift_card_batch.rb +4 -1
- data/app/models/spree/import.rb +5 -0
- data/app/models/spree/import_row.rb +12 -0
- data/app/models/spree/line_item.rb +6 -1
- data/app/models/spree/market.rb +32 -1
- data/app/models/spree/metafield.rb +38 -0
- data/app/models/spree/metafield_definition.rb +29 -6
- data/app/models/spree/metafields/json.rb +10 -0
- data/app/models/spree/newsletter_subscriber.rb +19 -3
- data/app/models/spree/option_type.rb +48 -7
- data/app/models/spree/order/checkout.rb +3 -3
- data/app/models/spree/order.rb +102 -6
- data/app/models/spree/order_approval.rb +19 -0
- data/app/models/spree/order_cancellation.rb +19 -0
- data/app/models/spree/order_routing/has_strategy_preference.rb +28 -0
- data/app/models/spree/order_routing/rules/default_location.rb +16 -0
- data/app/models/spree/order_routing/rules/minimize_splits.rb +45 -0
- data/app/models/spree/order_routing/rules/preferred_location.rb +22 -0
- data/app/models/spree/order_routing/strategy/base.rb +47 -0
- data/app/models/spree/order_routing/strategy/legacy.rb +33 -0
- data/app/models/spree/order_routing/strategy/reducer.rb +68 -0
- data/app/models/spree/order_routing/strategy/rules.rb +81 -0
- data/app/models/spree/order_routing_rule.rb +75 -0
- data/app/models/spree/permission_sets/configuration_management.rb +16 -0
- data/app/models/spree/permission_sets/product_display.rb +2 -0
- data/app/models/spree/permission_sets/product_management.rb +2 -0
- data/app/models/spree/price.rb +14 -1
- data/app/models/spree/price_list.rb +129 -17
- data/app/models/spree/price_rule.rb +11 -1
- data/app/models/spree/price_rules/customer_group_rule.rb +15 -1
- data/app/models/spree/price_rules/market_rule.rb +16 -1
- data/app/models/spree/price_rules/user_rule.rb +21 -2
- data/app/models/spree/product/channels.rb +149 -0
- data/app/models/spree/product/legacy_multi_store_support.rb +40 -0
- data/app/models/spree/product/slugs.rb +1 -1
- data/app/models/spree/product.rb +172 -31
- data/app/models/spree/product_publication.rb +43 -0
- data/app/models/spree/promotion/actions/create_adjustment.rb +4 -0
- data/app/models/spree/promotion/actions/create_item_adjustments.rb +4 -0
- data/app/models/spree/promotion/actions/create_line_items.rb +32 -14
- data/app/models/spree/promotion/rules/country.rb +40 -18
- data/app/models/spree/promotion/rules/customer_group.rb +10 -1
- data/app/models/spree/promotion/rules/product.rb +4 -0
- data/app/models/spree/promotion/rules/taxon.rb +24 -1
- data/app/models/spree/promotion/rules/user.rb +21 -0
- data/app/models/spree/promotion/rules/user_logged_in.rb +6 -0
- data/app/models/spree/promotion.rb +22 -1
- data/app/models/spree/promotion_action.rb +17 -11
- data/app/models/spree/promotion_rule.rb +17 -18
- data/app/models/spree/search_provider/meilisearch.rb +12 -2
- data/app/models/spree/stock/availability_validator.rb +1 -1
- data/app/models/spree/stock/quantifier.rb +89 -9
- data/app/models/spree/stock_item.rb +36 -0
- data/app/models/spree/stock_location.rb +52 -0
- data/app/models/spree/stock_reservation.rb +38 -0
- data/app/models/spree/stock_reservations/insufficient_stock_error.rb +12 -0
- data/app/models/spree/store.rb +18 -72
- data/app/models/spree/store_credit.rb +0 -8
- data/app/models/spree/store_product.rb +11 -23
- data/app/models/spree/taxon.rb +0 -5
- data/app/models/spree/user_identity.rb +1 -2
- data/app/models/spree/variant.rb +132 -18
- data/app/models/spree/variant_media.rb +46 -0
- data/app/models/spree/webhook_delivery.rb +1 -1
- data/app/models/spree/webhook_endpoint.rb +24 -0
- data/app/models/spree/wished_item.rb +0 -13
- data/app/presenters/spree/csv/product_variant_presenter.rb +23 -3
- data/app/presenters/spree/search_provider/product_presenter.rb +11 -4
- data/app/presenters/spree/variant_presenter.rb +4 -3
- data/app/services/spree/addresses/update.rb +6 -8
- data/app/services/spree/cart/add_item.rb +10 -0
- data/app/services/spree/cart/empty.rb +2 -0
- data/app/services/spree/cart/remove_line_item.rb +10 -0
- data/app/services/spree/cart/remove_out_of_stock_items.rb +1 -1
- data/app/services/spree/cart/set_quantity.rb +10 -0
- data/app/services/spree/carts/complete.rb +1 -0
- data/app/services/spree/carts/create.rb +1 -0
- data/app/services/spree/carts/update.rb +18 -2
- data/app/services/spree/carts/upsert_items.rb +6 -6
- data/app/services/spree/imports/row_processors/customer.rb +4 -1
- data/app/services/spree/imports/row_processors/product_variant.rb +95 -57
- data/app/services/spree/newsletter/link_user.rb +53 -0
- data/app/services/spree/newsletter/subscribe.rb +31 -9
- data/app/services/spree/orders/approve.rb +27 -6
- data/app/services/spree/orders/build_shipments.rb +29 -0
- data/app/services/spree/orders/cancel.rb +34 -3
- data/app/services/spree/orders/complete.rb +53 -0
- data/app/services/spree/orders/create.rb +156 -0
- data/app/services/spree/orders/update.rb +51 -0
- data/app/services/spree/orders/upsert_items.rb +70 -0
- data/app/services/spree/prices/bulk_upsert.rb +201 -0
- data/app/services/spree/products/duplicator.rb +1 -1
- data/app/services/spree/products/prepare_nested_attributes.rb +2 -30
- data/app/services/spree/sample_data/loader.rb +30 -0
- data/app/services/spree/stock_reservations/extend.rb +19 -0
- data/app/services/spree/stock_reservations/release.rb +12 -0
- data/app/services/spree/stock_reservations/reserve.rb +103 -0
- data/app/services/spree/taxons/remove_products.rb +7 -1
- data/app/subscribers/spree/product_metrics_subscriber.rb +3 -7
- data/app/views/spree/invitation_mailer/invitation_email.html.erb +4 -0
- data/config/locales/en.yml +27 -10
- data/config/routes.rb +9 -0
- data/db/migrate/20260429000001_create_spree_order_cancellations.rb +25 -0
- data/db/migrate/20260429000002_create_spree_order_approvals.rb +22 -0
- data/db/migrate/20260429000003_add_status_to_spree_orders.rb +6 -0
- data/db/migrate/20260429000004_add_scopes_to_spree_api_keys.rb +11 -0
- data/db/migrate/20260501000001_create_spree_stock_reservations.rb +19 -0
- data/db/migrate/20260507162651_create_spree_variant_media.rb +23 -0
- data/db/migrate/20260508175303_add_pickup_to_spree_stock_locations.rb +12 -0
- data/db/migrate/20260508204040_create_spree_channels.rb +18 -0
- data/db/migrate/20260508204041_create_spree_order_routing_rules.rb +18 -0
- data/db/migrate/20260508204042_add_preferred_stock_location_to_spree_orders.rb +5 -0
- data/db/migrate/20260508204043_add_channel_id_to_spree_orders.rb +10 -0
- data/db/migrate/20260511000001_backfill_status_on_spree_orders.rb +57 -0
- data/db/migrate/20260515000001_add_store_id_to_spree_newsletter_subscribers.rb +25 -0
- data/db/migrate/20260529000001_add_unique_index_to_spree_price_rules.rb +41 -0
- data/db/migrate/20260529000002_add_unique_index_to_spree_promotion_rules.rb +37 -0
- data/db/migrate/20260601000001_create_spree_product_publications.rb +14 -0
- data/db/migrate/20260601000002_add_store_id_to_spree_products.rb +16 -0
- data/db/migrate/20260602000001_add_default_to_spree_channels.rb +14 -0
- data/db/sample_data/channels.rb +12 -0
- data/db/sample_data/orders.rb +1 -1
- data/db/sample_data/products.csv +212 -212
- data/lib/generators/spree/api_resource/api_resource_generator.rb +353 -0
- data/lib/generators/spree/api_resource/templates/admin_controller.rb.tt +23 -0
- data/lib/generators/spree/api_resource/templates/admin_controller_spec.rb.tt +59 -0
- data/lib/generators/spree/api_resource/templates/admin_serializer.rb.tt +11 -0
- data/lib/generators/spree/api_resource/templates/factory.rb.tt +26 -0
- data/lib/generators/spree/api_resource/templates/store_aliased_serializer.rb.tt +12 -0
- data/lib/generators/spree/api_resource/templates/store_controller.rb.tt +31 -0
- data/lib/generators/spree/api_resource/templates/store_controller_spec.rb.tt +61 -0
- data/lib/generators/spree/api_resource/templates/store_serializer.rb.tt +14 -0
- data/lib/generators/spree/controller_decorator/controller_decorator_generator.rb +66 -0
- data/lib/generators/spree/controller_decorator/templates/controller_decorator.rb.tt +25 -0
- data/lib/generators/spree/model/model_generator.rb +73 -7
- data/lib/generators/spree/model/templates/create_table_migration.rb.tt +40 -0
- data/lib/generators/spree/model/templates/model.rb.tt +28 -2
- data/lib/spree/core/configuration.rb +7 -0
- data/lib/spree/core/controller_helpers/auth.rb +0 -12
- data/lib/spree/core/controller_helpers/currency.rb +0 -17
- data/lib/spree/core/controller_helpers/order.rb +0 -19
- data/lib/spree/core/dependencies.rb +5 -2
- data/lib/spree/core/engine.rb +54 -7
- data/lib/spree/core/permission_configuration.rb +15 -0
- data/lib/spree/core/preferences/masking.rb +47 -0
- data/lib/spree/core/preferences/preferable_class_methods.rb +7 -1
- data/lib/spree/core/version.rb +1 -1
- data/lib/spree/core.rb +56 -5
- data/lib/spree/permitted_attributes.rb +9 -7
- data/lib/spree/testing_support/factories/address_factory.rb +16 -9
- data/lib/spree/testing_support/factories/api_key_factory.rb +1 -0
- data/lib/spree/testing_support/factories/channel_factory.rb +8 -0
- data/lib/spree/testing_support/factories/line_item_factory.rb +2 -8
- data/lib/spree/testing_support/factories/newsletter_subscriber_factory.rb +2 -0
- data/lib/spree/testing_support/factories/product_factory.rb +16 -7
- data/lib/spree/testing_support/factories/product_publication_factory.rb +6 -0
- data/lib/spree/testing_support/factories/refresh_token_factory.rb +15 -0
- data/lib/spree/testing_support/factories/stock_location_factory.rb +2 -2
- data/lib/spree/testing_support/factories/stock_reservation_factory.rb +31 -0
- data/lib/spree/testing_support/factories/variant_factory.rb +3 -3
- data/lib/spree/testing_support/order_walkthrough.rb +1 -1
- data/lib/spree/testing_support/store.rb +10 -0
- data/lib/spree/upgrades/5_4_to_5_5/manifest.yml +53 -0
- data/lib/tasks/channels.rake +94 -0
- data/lib/tasks/core.rake +1 -0
- data/lib/tasks/media.rake +27 -0
- data/lib/tasks/products.rake +4 -6
- data/lib/tasks/publications.rake +60 -0
- data/lib/tasks/upgrade.rake +211 -0
- metadata +83 -18
- data/app/finders/spree/variants/visible_finder.rb +0 -23
- data/app/paginators/spree/shared/paginate.rb +0 -30
- data/app/presenters/spree/filters/price_presenter.rb +0 -23
- data/app/presenters/spree/filters/price_range_presenter.rb +0 -30
- data/app/presenters/spree/filters/quantified_price_range_presenter.rb +0 -45
- data/app/presenters/spree/product_summary_presenter.rb +0 -27
- data/app/presenters/spree/variants/options_presenter.rb +0 -82
- data/app/services/spree/classifications/reposition.rb +0 -23
- data/app/sorters/spree/orders/sort.rb +0 -10
- data/lib/spree/core/controller_helpers/common.rb +0 -14
- data/lib/spree/core/token_generator.rb +0 -23
- data/lib/spree/database_type_utilities.rb +0 -22
- data/lib/spree/testing_support/bar_ability.rb +0 -14
- data/lib/spree/testing_support/factories/store_product_factory.rb +0 -6
|
@@ -40,6 +40,27 @@ module Spree
|
|
|
40
40
|
super(attributes)
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
+
# API-facing alias for the 6.0 rename (`5.4-6.0-custom-fields-rename.md`):
|
|
44
|
+
# callers see "custom fields" everywhere even though the underlying
|
|
45
|
+
# tables/columns still use `metafield*`. Reached via flat params on the
|
|
46
|
+
# admin API v3 (`custom_fields: [...]`); also works through
|
|
47
|
+
# `Model.new(permitted_params)` since Rails routes the key to this writer.
|
|
48
|
+
#
|
|
49
|
+
# Upsert semantics by `custom_field_definition_id`: existing entries
|
|
50
|
+
# for the same definition are updated, missing entries are created.
|
|
51
|
+
# Partial: definitions NOT in the array are left untouched, so the
|
|
52
|
+
# client can patch one field at a time without resending the rest.
|
|
53
|
+
# Blank values on an existing metafield destroy it (mirrors the dedicated
|
|
54
|
+
# endpoint's behavior via `metafields_attributes=`).
|
|
55
|
+
def custom_fields=(attributes)
|
|
56
|
+
return if attributes.blank?
|
|
57
|
+
return super(attributes) if attributes.first.is_a?(Spree::Metafield)
|
|
58
|
+
|
|
59
|
+
assign_custom_field_attrs(attributes)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
after_save :apply_pending_custom_fields, if: -> { @pending_custom_field_attrs.present? }
|
|
63
|
+
|
|
43
64
|
scope :with_metafield_key, ->(key_with_namespace) {
|
|
44
65
|
namespace, key = extract_namespace_and_key(key_with_namespace)
|
|
45
66
|
joins(metafields: :metafield_definition).where(spree_metafield_definitions: { namespace: namespace, key: key })
|
|
@@ -56,11 +77,44 @@ module Spree
|
|
|
56
77
|
self.class.extract_namespace_and_key(key_with_namespace)
|
|
57
78
|
end
|
|
58
79
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
80
|
+
# Upsert a single custom field value on this resource. The first
|
|
81
|
+
# argument locates the definition by any of:
|
|
82
|
+
#
|
|
83
|
+
# - `"namespace.key"` string — auto-creates the definition if missing
|
|
84
|
+
# (backend-internal callers that don't know the id upfront).
|
|
85
|
+
# - {Spree::MetafieldDefinition} instance.
|
|
86
|
+
# - Integer / numeric String — raw definition id.
|
|
87
|
+
# - Prefixed-id String (`"cfdef_..."`) — decoded to the definition id.
|
|
88
|
+
#
|
|
89
|
+
# Blank values (nil or empty/whitespace string) destroy any existing
|
|
90
|
+
# metafield for the definition. Empty containers (`[]`, `{}`) and
|
|
91
|
+
# numeric / boolean falsy values are real values, not blanks.
|
|
92
|
+
#
|
|
93
|
+
# @param definition_or_key [String, Integer, Spree::MetafieldDefinition]
|
|
94
|
+
# @param value [Object] the value to persist; type is enforced by the
|
|
95
|
+
# typed metafield subclass (Boolean, Number, Json, ShortText, …).
|
|
96
|
+
# @return [Spree::Metafield, nil] the persisted metafield, or nil when
|
|
97
|
+
# the value was blank and any existing row was destroyed.
|
|
98
|
+
# @raise [ArgumentError] if `definition_or_key` doesn't resolve to a
|
|
99
|
+
# known definition.
|
|
100
|
+
def set_metafield(definition_or_key, value)
|
|
101
|
+
definition_id = resolve_metafield_definition_id(definition_or_key)
|
|
102
|
+
metafield = metafields.find_or_initialize_by(metafield_definition_id: definition_id)
|
|
103
|
+
if value_blank?(value)
|
|
104
|
+
metafield.destroy if metafield.persisted?
|
|
105
|
+
return nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# JSON metafields store canonical JSON in the underlying text column.
|
|
109
|
+
# Coerce Hash/Array values BEFORE assignment, since the STI subclass
|
|
110
|
+
# (`Spree::Metafields::Json`) isn't switched on until before_validation,
|
|
111
|
+
# so its custom `value=` writer doesn't run yet on a fresh
|
|
112
|
+
# `find_or_initialize_by` record.
|
|
113
|
+
if (value.is_a?(Hash) || value.is_a?(Array)) &&
|
|
114
|
+
metafield.metafield_definition&.metafield_type == 'Spree::Metafields::Json'
|
|
115
|
+
value = value.to_json
|
|
116
|
+
end
|
|
62
117
|
|
|
63
|
-
metafield = metafields.find_or_initialize_by(metafield_definition: metafield_definition)
|
|
64
118
|
metafield.value = value
|
|
65
119
|
metafield.save!
|
|
66
120
|
metafield
|
|
@@ -86,8 +140,116 @@ module Spree
|
|
|
86
140
|
|
|
87
141
|
private
|
|
88
142
|
|
|
143
|
+
# Decide whether a metafield value should trigger the destroy-existing
|
|
144
|
+
# branch. Ruby's `blank?` reports `false`, `0`, `[]`, `{}` as blank, but
|
|
145
|
+
# for typed metafields those are real values:
|
|
146
|
+
#
|
|
147
|
+
# - Boolean `false` / Numeric `0` — real values, never destroy.
|
|
148
|
+
# - Empty Array / Hash — a JSON metafield storing `[]` or `{}` is a
|
|
149
|
+
# meaningful value (an empty list / object), not a clear signal.
|
|
150
|
+
#
|
|
151
|
+
# Only `nil` and empty/whitespace strings count as "missing".
|
|
152
|
+
#
|
|
153
|
+
# @param value [Object]
|
|
154
|
+
# @return [Boolean]
|
|
89
155
|
def value_blank?(value)
|
|
90
|
-
value.
|
|
156
|
+
return true if value.nil?
|
|
157
|
+
return value.strip.empty? if value.is_a?(String)
|
|
158
|
+
|
|
159
|
+
false
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def assign_custom_field_attrs(attributes)
|
|
163
|
+
if new_record?
|
|
164
|
+
# Persisting metafields requires a persisted parent (resource_id NOT
|
|
165
|
+
# NULL). Stash the attrs and replay them after the parent is saved.
|
|
166
|
+
@pending_custom_field_attrs = attributes
|
|
167
|
+
return
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
apply_custom_field_attrs(attributes)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def apply_pending_custom_fields
|
|
174
|
+
attrs = @pending_custom_field_attrs
|
|
175
|
+
@pending_custom_field_attrs = nil
|
|
176
|
+
apply_custom_field_attrs(attrs)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def apply_custom_field_attrs(attributes)
|
|
180
|
+
attributes = attributes.values if attributes.is_a?(Hash)
|
|
181
|
+
attributes.each_with_index do |raw, index|
|
|
182
|
+
attrs = raw.respond_to?(:to_h) ? raw.to_h : raw
|
|
183
|
+
attrs = attrs.with_indifferent_access
|
|
184
|
+
definition_id = attrs[:metafield_definition_id] || attrs[:custom_field_definition_id]
|
|
185
|
+
next if definition_id.blank?
|
|
186
|
+
|
|
187
|
+
begin
|
|
188
|
+
set_metafield(definition_id, attrs[:value])
|
|
189
|
+
rescue ArgumentError => e
|
|
190
|
+
# Convert an unknown / malformed definition id into a field-level
|
|
191
|
+
# validation error so the controller returns 422 with structured
|
|
192
|
+
# `details`, instead of leaking ArgumentError as a 400/500.
|
|
193
|
+
errors.add("custom_fields[#{index}].custom_field_definition_id", e.message)
|
|
194
|
+
raise ActiveRecord::RecordInvalid, self
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Resolve any of the supported reference shapes to a raw definition id.
|
|
200
|
+
# See {#set_metafield} for the accepted shapes.
|
|
201
|
+
#
|
|
202
|
+
# @param definition_or_key [String, Integer, Spree::MetafieldDefinition]
|
|
203
|
+
# @return [Integer, String] the definition's primary key value (Integer
|
|
204
|
+
# for legacy integer-id setups, String for UUID setups).
|
|
205
|
+
# @raise [ArgumentError] for unknown / malformed input.
|
|
206
|
+
def resolve_metafield_definition_id(definition_or_key)
|
|
207
|
+
case definition_or_key
|
|
208
|
+
when Spree::MetafieldDefinition
|
|
209
|
+
definition_or_key.id
|
|
210
|
+
when Integer
|
|
211
|
+
definition_or_key
|
|
212
|
+
when String
|
|
213
|
+
resolve_metafield_definition_id_from_string(definition_or_key)
|
|
214
|
+
else
|
|
215
|
+
raise ArgumentError, "Invalid definition_or_key: #{definition_or_key.inspect}"
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# @param value [String] one of: `"namespace.key"`, a prefixed id
|
|
220
|
+
# (`"cfdef_..."`), or a bare numeric id (`"42"`).
|
|
221
|
+
# @return [Integer, String] the resolved definition's primary key value.
|
|
222
|
+
# @raise [ArgumentError] if the string doesn't match any known shape.
|
|
223
|
+
def resolve_metafield_definition_id_from_string(value)
|
|
224
|
+
# `"namespace.key"` — backend-internal callers that don't know the id;
|
|
225
|
+
# auto-create the definition if missing.
|
|
226
|
+
if value.include?('.')
|
|
227
|
+
namespace, key = extract_namespace_and_key(value)
|
|
228
|
+
return Spree::MetafieldDefinition.find_or_create_by!(
|
|
229
|
+
namespace: namespace, key: key, resource_type: self.class.name
|
|
230
|
+
).id
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Prefixed id (`"cfdef_..."`/`"mfd_..."`). Use the canonical predicate
|
|
234
|
+
# so single-segment names with underscores (e.g. `"product_specs"`)
|
|
235
|
+
# don't get mistaken for prefixed ids. We must verify the decoded id
|
|
236
|
+
# actually exists — Sqids will happily decode any all-lowercase
|
|
237
|
+
# alphanumeric string to a phantom integer; without the existence
|
|
238
|
+
# check `find_or_initialize_by(metafield_definition_id: phantom_id)`
|
|
239
|
+
# would later raise a confusing "Metafield definition must exist"
|
|
240
|
+
# 422 instead of an "unknown id" 422.
|
|
241
|
+
if Spree::PrefixedId.prefixed_id?(value)
|
|
242
|
+
decoded = Spree::MetafieldDefinition.decode_prefixed_id(value)
|
|
243
|
+
existing = decoded && Spree::MetafieldDefinition.find_by(id: decoded)
|
|
244
|
+
raise ArgumentError, "Unknown metafield definition id: #{value.inspect}" if existing.nil?
|
|
245
|
+
|
|
246
|
+
return existing.id
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Bare numeric id ("42"). Reject anything else outright.
|
|
250
|
+
raise ArgumentError, "Invalid metafield definition reference: #{value.inspect}" unless /\A\d+\z/.match?(value)
|
|
251
|
+
|
|
252
|
+
value.to_i
|
|
91
253
|
end
|
|
92
254
|
end
|
|
93
255
|
end
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
# Adds class-level helpers that surface a JSON-friendly description of
|
|
3
|
+
# a `Preferable` class's `preference :name, :type, default:` declarations.
|
|
4
|
+
#
|
|
5
|
+
# Used by the admin API (`/payment_methods/types`, `/promotion_actions/types`,
|
|
6
|
+
# `/promotion_rules/types`) so that admin UIs can render configuration forms
|
|
7
|
+
# for any provider/action/rule subclass without hard-coding field lists.
|
|
8
|
+
module PreferenceSchema
|
|
9
|
+
extend ActiveSupport::Concern
|
|
10
|
+
|
|
11
|
+
delegate :preference_schema, :serialized_preference_schema, :password_preference_keys, to: :class
|
|
12
|
+
|
|
13
|
+
# Wire-safe view of `preferences` with `:password`-typed values
|
|
14
|
+
# masked. Lives here (not in the serializer) so any consumer holding
|
|
15
|
+
# a Preferable instance gets the same safety guarantee — secrets
|
|
16
|
+
# must never leave the server in plaintext. Keys are stringified to
|
|
17
|
+
# match the wire shape expected by JSON clients.
|
|
18
|
+
#
|
|
19
|
+
# @return [Hash{String => Object}]
|
|
20
|
+
def serialized_preferences
|
|
21
|
+
Spree::Preferences::Masking.serialize(self)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class_methods do
|
|
25
|
+
# Returns `[{ key:, type:, default: }]` for every preference declared
|
|
26
|
+
# on this class (and its ancestors). Skips deprecated preferences.
|
|
27
|
+
#
|
|
28
|
+
# Memoized at class load — the schema is derived from the static
|
|
29
|
+
# `preference :name, :type` declarations, so it can never change at
|
|
30
|
+
# runtime. Each entry also caches `key_string` (frozen) so hot-path
|
|
31
|
+
# serializers don't allocate `pref.to_s` per request.
|
|
32
|
+
def preference_schema
|
|
33
|
+
@preference_schema ||= compute_preference_schema
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Wire-safe variant of `preference_schema` with `:password`
|
|
37
|
+
# defaults nilled out. A gateway author can set a non-empty
|
|
38
|
+
# default for a `:password` preference; without this redaction the
|
|
39
|
+
# default leaks alongside the masked live value. Memoized so admin
|
|
40
|
+
# index responses don't re-allocate per row.
|
|
41
|
+
#
|
|
42
|
+
# Strips `:key_string` — that's a server-only cache used by
|
|
43
|
+
# `Masking.serialize` to avoid `to_s` allocations per request, not
|
|
44
|
+
# part of the documented `{ key, type, default }` wire shape.
|
|
45
|
+
def serialized_preference_schema
|
|
46
|
+
@serialized_preference_schema ||= preference_schema.map do |field|
|
|
47
|
+
wire = { key: field[:key], type: field[:type], default: field[:default] }
|
|
48
|
+
wire[:default] = nil if field[:type] == :password
|
|
49
|
+
wire.freeze
|
|
50
|
+
end.freeze
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Set of `:password`-typed preference keys for this class. Memoized
|
|
54
|
+
# so write-side guards (e.g. the masked-round-trip check) don't
|
|
55
|
+
# walk the schema or fall back to a `rescue NoMethodError`.
|
|
56
|
+
def password_preference_keys
|
|
57
|
+
@password_preference_keys ||= preference_schema
|
|
58
|
+
.each_with_object(Set.new) { |field, set| set << field[:key] if field[:type] == :password }
|
|
59
|
+
.freeze
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def compute_preference_schema
|
|
63
|
+
instance = new
|
|
64
|
+
instance.defined_preferences.filter_map do |pref|
|
|
65
|
+
next if instance.preference_deprecated(pref)
|
|
66
|
+
|
|
67
|
+
{
|
|
68
|
+
key: pref,
|
|
69
|
+
key_string: pref.to_s.freeze,
|
|
70
|
+
type: instance.preference_type(pref),
|
|
71
|
+
default: safe_preference_default(instance, pref)
|
|
72
|
+
}.freeze
|
|
73
|
+
end
|
|
74
|
+
rescue StandardError
|
|
75
|
+
[]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Builds a `parse_on_set:` lambda for `preference :foo_ids, :array`
|
|
79
|
+
# declarations that accept prefixed IDs (e.g. `cg_…`, `mkt_…`) from
|
|
80
|
+
# the API. Splits comma-separated entries, strips whitespace, and
|
|
81
|
+
# decodes any prefixed IDs to raw IDs so eligibility checks compare
|
|
82
|
+
# against `belongs_to` foreign keys directly.
|
|
83
|
+
#
|
|
84
|
+
# When `klass` is nil, prefixed-ID decoding is skipped — used for
|
|
85
|
+
# ISO/string-keyed preferences where the value is the identifier
|
|
86
|
+
# (e.g. country `:country_isos`).
|
|
87
|
+
#
|
|
88
|
+
# When `scope:` is given, the existence check runs through the
|
|
89
|
+
# scope relation derived from the owning record — prevents a
|
|
90
|
+
# rule from being persisted with IDs that belong to another
|
|
91
|
+
# store (e.g. a Market rule referencing markets from a different
|
|
92
|
+
# store). The proc receives the rule instance.
|
|
93
|
+
#
|
|
94
|
+
# @param klass [Class<Spree::Base>, nil] AR class used to resolve
|
|
95
|
+
# prefixed IDs via Sqids decoding + a single existence check.
|
|
96
|
+
# @param scope [Proc, nil] optional `->(rule) { rule.price_list.store.markets }`
|
|
97
|
+
# relation builder; defaults to the unscoped `klass`.
|
|
98
|
+
# @return [Proc] suitable for the `parse_on_set:` preference option.
|
|
99
|
+
def normalize_id_preference(klass: nil, scope: nil)
|
|
100
|
+
lambda do |values, owner = nil|
|
|
101
|
+
raw = Array(values).flat_map { |v| v.to_s.split(',') }.compact_blank.map(&:strip)
|
|
102
|
+
next raw unless klass
|
|
103
|
+
|
|
104
|
+
decoded = raw.map do |v|
|
|
105
|
+
Spree::PrefixedId.prefixed_id?(v) ? Spree::PrefixedId.decode_prefixed_id(v).to_s : v
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
relation = scope && owner ? scope.call(owner) : klass
|
|
109
|
+
found = relation.where(id: decoded).pluck(:id).map(&:to_s).to_set
|
|
110
|
+
missing = decoded.reject { |id| found.include?(id) }
|
|
111
|
+
raise ActiveRecord::RecordNotFound.new(
|
|
112
|
+
"Couldn't find #{klass.name} with id=#{missing.join(',')}", klass.name
|
|
113
|
+
) if missing.any?
|
|
114
|
+
|
|
115
|
+
decoded
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Resolve a wire-format shorthand back to its registered subclass.
|
|
120
|
+
# Returns nil for unknown shorthands. Lookup is registry-driven so
|
|
121
|
+
# removed/foreign subclasses can't be smuggled in.
|
|
122
|
+
def find_by_api_type(shorthand)
|
|
123
|
+
return nil if shorthand.blank?
|
|
124
|
+
|
|
125
|
+
registered_subclasses.find { |klass| klass.api_type == shorthand.to_s }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Returns a `[{ type:, label:, description:, preference_schema: }]`
|
|
129
|
+
# array for every concrete subclass in `subclasses`. Sorted by label
|
|
130
|
+
# for stable output. Uses `serialized_preference_schema` so
|
|
131
|
+
# `:password` defaults are redacted — `/types` is an unauthenticated
|
|
132
|
+
# discovery surface and must never leak gateway-shipped defaults.
|
|
133
|
+
def subclasses_with_preference_schema
|
|
134
|
+
registered_subclasses.map do |klass|
|
|
135
|
+
{
|
|
136
|
+
type: klass.api_type,
|
|
137
|
+
label: subclass_label(klass),
|
|
138
|
+
description: klass.respond_to?(:description) ? klass.description : nil,
|
|
139
|
+
preference_schema: klass.respond_to?(:serialized_preference_schema) ? klass.serialized_preference_schema : []
|
|
140
|
+
}
|
|
141
|
+
end.sort_by { |entry| entry[:label] }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# STI subclasses share the parent's `model_name`, so calling
|
|
145
|
+
# `klass.model_name.human` would return "Payment Method" for every
|
|
146
|
+
# entry. Subclasses can override by defining a class-level
|
|
147
|
+
# `display_name`. Otherwise:
|
|
148
|
+
#
|
|
149
|
+
# Spree::PaymentMethod::Check → "Check"
|
|
150
|
+
# Spree::Gateway::Bogus → "Bogus"
|
|
151
|
+
# SpreeStripe::Gateway → "Stripe"
|
|
152
|
+
# SpreeAdyen::Gateway → "Adyen"
|
|
153
|
+
#
|
|
154
|
+
# The "Gateway" branch handles the gem convention where each
|
|
155
|
+
# provider gem ships a top-level `Gateway` class (so demodulize
|
|
156
|
+
# would collapse them all to "Gateway"). Fall back to the outer
|
|
157
|
+
# module, with a leading `Spree` namespace stripped.
|
|
158
|
+
def subclass_label(klass)
|
|
159
|
+
return klass.display_name if klass.respond_to?(:display_name) && klass.display_name.present?
|
|
160
|
+
return klass.human_name if klass.respond_to?(:human_name) && klass.human_name.present?
|
|
161
|
+
|
|
162
|
+
leaf = klass.to_s.demodulize
|
|
163
|
+
return leaf.titleize unless leaf == 'Gateway'
|
|
164
|
+
|
|
165
|
+
outer = klass.to_s.split('::').first.to_s
|
|
166
|
+
outer.delete_prefix('Spree').presence&.titleize || leaf.titleize
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
# Each STI parent (PaymentMethod, PromotionAction, PromotionRule)
|
|
172
|
+
# already exposes its registry — we just route to the right one.
|
|
173
|
+
# Override in the including class to add support for custom parents.
|
|
174
|
+
def registered_subclasses
|
|
175
|
+
return providers if respond_to?(:providers)
|
|
176
|
+
return Spree.promotions.actions if name == 'Spree::PromotionAction'
|
|
177
|
+
return Spree.promotions.rules if name == 'Spree::PromotionRule'
|
|
178
|
+
|
|
179
|
+
[]
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Defaults can be Procs that hit the database (e.g. `Spree::Store.default`);
|
|
183
|
+
# those aren't safe to evaluate at request time, so we stringify them.
|
|
184
|
+
def safe_preference_default(instance, pref)
|
|
185
|
+
instance.preference_default(pref)
|
|
186
|
+
rescue StandardError
|
|
187
|
+
nil
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -20,6 +20,35 @@ module Spree
|
|
|
20
20
|
class_attribute :_prefix_id_prefix, instance_writer: false
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
# Automatically resolve prefixed ID strings for belongs_to foreign keys.
|
|
24
|
+
# e.g., product.assign_attributes(tax_category_id: "tc_86Rf07xd4z") will
|
|
25
|
+
# decode the prefixed ID to the integer primary key.
|
|
26
|
+
def assign_attributes(new_attributes)
|
|
27
|
+
return super if new_attributes.blank?
|
|
28
|
+
|
|
29
|
+
attrs = new_attributes.to_h
|
|
30
|
+
needs_resolution = attrs.any? do |key, value|
|
|
31
|
+
key_s = key.to_s
|
|
32
|
+
(value.is_a?(String) && key_s.end_with?('_id') && Spree::PrefixedId.prefixed_id?(value)) ||
|
|
33
|
+
(value.is_a?(Array) && key_s.end_with?('_ids') && value.any? { |v| Spree::PrefixedId.prefixed_id?(v) })
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
return super unless needs_resolution
|
|
37
|
+
|
|
38
|
+
resolved = attrs.each_with_object({}.with_indifferent_access) do |(key, value), hash|
|
|
39
|
+
key_s = key.to_s
|
|
40
|
+
if value.is_a?(String) && key_s.end_with?('_id') && Spree::PrefixedId.prefixed_id?(value)
|
|
41
|
+
hash[key] = self.class.resolve_prefixed_id_for_attribute(key_s, value)
|
|
42
|
+
elsif value.is_a?(Array) && key_s.end_with?('_ids')
|
|
43
|
+
hash[key] = self.class.resolve_prefixed_ids_for_attribute(key_s, value)
|
|
44
|
+
else
|
|
45
|
+
hash[key] = value
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
super(resolved)
|
|
50
|
+
end
|
|
51
|
+
|
|
23
52
|
# Returns the Stripe-style prefixed ID, or nil for unsaved records.
|
|
24
53
|
def prefixed_id
|
|
25
54
|
return nil unless id.present?
|
|
@@ -36,35 +65,89 @@ module Spree
|
|
|
36
65
|
prefixed_id.presence || super
|
|
37
66
|
end
|
|
38
67
|
|
|
68
|
+
# Module-level methods for use without a model context (e.g., from ParamsNormalizer)
|
|
69
|
+
def self.prefixed_id?(value)
|
|
70
|
+
value.is_a?(String) && value.match?(/\A[a-z]+_[a-zA-Z0-9]+\z/)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.decode_prefixed_id(prefixed_id_string)
|
|
74
|
+
return nil if prefixed_id_string.blank?
|
|
75
|
+
|
|
76
|
+
parts = prefixed_id_string.to_s.split('_', 2)
|
|
77
|
+
return nil if parts.length != 2
|
|
78
|
+
|
|
79
|
+
_prefix, encoded = parts
|
|
80
|
+
ids = SQIDS.decode(encoded)
|
|
81
|
+
ids.first
|
|
82
|
+
end
|
|
83
|
+
|
|
39
84
|
class_methods do
|
|
40
85
|
def has_prefix_id(prefix)
|
|
41
86
|
self._prefix_id_prefix = prefix.to_s
|
|
42
87
|
end
|
|
43
88
|
|
|
89
|
+
def prefixed_id?(value)
|
|
90
|
+
Spree::PrefixedId.prefixed_id?(value)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Memoized map of foreign_key → belongs_to reflection for prefixed ID resolution.
|
|
94
|
+
def belongs_to_reflections_by_fk
|
|
95
|
+
@belongs_to_reflections_by_fk ||= reflect_on_all_associations(:belongs_to)
|
|
96
|
+
.reject(&:polymorphic?)
|
|
97
|
+
.index_by { |a| a.foreign_key.to_s }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Resolve a prefixed ID string for a belongs_to foreign key attribute.
|
|
101
|
+
# Uses the association's target class to validate the record exists.
|
|
102
|
+
# Only resolves when a matching belongs_to association exists — columns
|
|
103
|
+
# like external_id that happen to end with _id are left untouched.
|
|
104
|
+
# Resolves aliased FKs (via alias_attribute) to their canonical name.
|
|
105
|
+
def resolve_prefixed_id_for_attribute(attribute_name, prefixed_id_value)
|
|
106
|
+
canonical_name = attribute_aliases[attribute_name] || attribute_name
|
|
107
|
+
reflection = belongs_to_reflections_by_fk[canonical_name]
|
|
108
|
+
|
|
109
|
+
if reflection
|
|
110
|
+
reflection.klass.find_by_param!(prefixed_id_value).id
|
|
111
|
+
else
|
|
112
|
+
prefixed_id_value
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Resolve an array of prefixed IDs for a has_many _ids setter.
|
|
117
|
+
# Infers the target class from the association name (e.g., taxon_ids → taxons → Spree::Taxon).
|
|
118
|
+
# Only resolves when a matching association exists.
|
|
119
|
+
def resolve_prefixed_ids_for_attribute(attribute_name, values)
|
|
120
|
+
association_name = attribute_name.sub(/_ids$/, '').pluralize
|
|
121
|
+
reflection = reflect_on_association(association_name.to_sym)
|
|
122
|
+
klass = reflection&.klass
|
|
123
|
+
|
|
124
|
+
return values unless klass
|
|
125
|
+
|
|
126
|
+
values.map do |v|
|
|
127
|
+
if Spree::PrefixedId.prefixed_id?(v)
|
|
128
|
+
klass.find_by_param!(v).id
|
|
129
|
+
else
|
|
130
|
+
v
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
44
135
|
def find_by_prefix_id!(prefixed_id)
|
|
45
|
-
decoded = decode_prefixed_id(prefixed_id)
|
|
136
|
+
decoded = Spree::PrefixedId.decode_prefixed_id(prefixed_id)
|
|
46
137
|
raise ActiveRecord::RecordNotFound.new("Couldn't find #{name} with prefixed id=#{prefixed_id}", name) unless decoded
|
|
47
138
|
|
|
48
139
|
find(decoded)
|
|
49
140
|
end
|
|
50
141
|
|
|
51
142
|
def find_by_prefix_id(prefixed_id)
|
|
52
|
-
decoded = decode_prefixed_id(prefixed_id)
|
|
143
|
+
decoded = Spree::PrefixedId.decode_prefixed_id(prefixed_id)
|
|
53
144
|
return nil unless decoded
|
|
54
145
|
|
|
55
146
|
find_by(id: decoded)
|
|
56
147
|
end
|
|
57
148
|
|
|
58
|
-
# Decode a prefixed ID string (e.g., "prod_86Rf07xd4z") to the integer primary key.
|
|
59
149
|
def decode_prefixed_id(prefixed_id_string)
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
parts = prefixed_id_string.to_s.split('_', 2)
|
|
63
|
-
return nil if parts.length != 2
|
|
64
|
-
|
|
65
|
-
_prefix, encoded = parts
|
|
66
|
-
ids = Spree::PrefixedId::SQIDS.decode(encoded)
|
|
67
|
-
ids.first
|
|
150
|
+
Spree::PrefixedId.decode_prefixed_id(prefixed_id_string)
|
|
68
151
|
end
|
|
69
152
|
|
|
70
153
|
# Find by prefixed ID first, falling back to integer id for backwards compatibility.
|
|
@@ -286,11 +286,12 @@ module Spree
|
|
|
286
286
|
}
|
|
287
287
|
|
|
288
288
|
def self.not_discontinued(only_not_discontinued = true)
|
|
289
|
-
if only_not_discontinued
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
289
|
+
return all if only_not_discontinued == '0' || !only_not_discontinued
|
|
290
|
+
|
|
291
|
+
channel = Spree::Current.channel
|
|
292
|
+
return where(discontinue_on: [nil, Time.current.beginning_of_minute..]) unless channel
|
|
293
|
+
|
|
294
|
+
for_channel(channel).where(Spree::ProductPublication.table_name => { unpublished_at: [nil, Time.current.beginning_of_minute..] })
|
|
294
295
|
end
|
|
295
296
|
|
|
296
297
|
def self.with_currency(currency)
|
|
@@ -300,11 +301,26 @@ module Spree
|
|
|
300
301
|
distinct
|
|
301
302
|
end
|
|
302
303
|
|
|
304
|
+
# @param available_on [Time, nil] cutoff for the published_at filter.
|
|
305
|
+
# When passed, products are only included if their current-channel
|
|
306
|
+
# publication's +published_at+ is at or before this time. When +nil+,
|
|
307
|
+
# published_at is not filtered — matches legacy semantics where
|
|
308
|
+
# +.available+ without args returned future-dated products too.
|
|
309
|
+
# @param currency [String, nil] currency to require a price in; nil
|
|
310
|
+
# falls back to the default store's default currency.
|
|
303
311
|
def self.available(available_on = nil, currency = nil)
|
|
312
|
+
cutoff = available_on
|
|
313
|
+
cutoff = cutoff.beginning_of_minute if cutoff.respond_to?(:beginning_of_minute)
|
|
314
|
+
|
|
304
315
|
scope = not_discontinued.where(status: 'active')
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
scope =
|
|
316
|
+
|
|
317
|
+
if cutoff
|
|
318
|
+
scope = if (channel = Spree::Current.channel)
|
|
319
|
+
scope.for_channel(channel)
|
|
320
|
+
.where(Spree::ProductPublication.table_name => { published_at: [nil, ..cutoff] })
|
|
321
|
+
else
|
|
322
|
+
scope.where(Product.table_name => { available_on: ..cutoff })
|
|
323
|
+
end
|
|
308
324
|
end
|
|
309
325
|
|
|
310
326
|
unless Spree::Config.show_products_without_price
|
|
@@ -315,6 +331,10 @@ module Spree
|
|
|
315
331
|
scope
|
|
316
332
|
end
|
|
317
333
|
|
|
334
|
+
def self.for_channel(channel)
|
|
335
|
+
joins(:product_publications).where(Spree::ProductPublication.table_name => { channel_id: channel.id })
|
|
336
|
+
end
|
|
337
|
+
|
|
318
338
|
def self.active(currency = nil)
|
|
319
339
|
available(nil, currency)
|
|
320
340
|
end
|
|
@@ -343,19 +363,18 @@ module Spree
|
|
|
343
363
|
group('spree_products.id').joins(:taxons).where(Taxon.arel_table[:name].eq(name))
|
|
344
364
|
end
|
|
345
365
|
|
|
346
|
-
# Orders products by best
|
|
347
|
-
#
|
|
348
|
-
#
|
|
349
|
-
#
|
|
350
|
-
# and work with DISTINCT (same pattern as the price sorting scopes).
|
|
366
|
+
# Orders products by best-selling metrics (+units_sold_count+, +revenue+)
|
|
367
|
+
# tracked directly on +spree_products+. Uses Arel::Nodes::As so that
|
|
368
|
+
# ORDER BY expressions appear in SELECT and work with DISTINCT (same
|
|
369
|
+
# pattern as the price sorting scopes).
|
|
351
370
|
scope :by_best_selling, ->(order_direction = :desc) {
|
|
352
|
-
|
|
353
|
-
units_expr = Arel.sql("COALESCE(#{
|
|
354
|
-
revenue_expr = Arel.sql("COALESCE(#{
|
|
371
|
+
p_table = Product.table_name
|
|
372
|
+
units_expr = Arel.sql("COALESCE(#{p_table}.units_sold_count, 0)")
|
|
373
|
+
revenue_expr = Arel.sql("COALESCE(#{p_table}.revenue, 0)")
|
|
355
374
|
|
|
356
375
|
order_dir = order_direction == :desc ? :desc : :asc
|
|
357
376
|
|
|
358
|
-
select("#{
|
|
377
|
+
select("#{p_table}.*").
|
|
359
378
|
select(Arel::Nodes::As.new(units_expr, Arel.sql('best_selling_units'))).
|
|
360
379
|
select(Arel::Nodes::As.new(revenue_expr, Arel.sql('best_selling_revenue'))).
|
|
361
380
|
order(units_expr.send(order_dir)).
|
|
@@ -6,7 +6,11 @@ module Spree::RansackableAttributes
|
|
|
6
6
|
class_attribute :whitelisted_ransackable_scopes
|
|
7
7
|
|
|
8
8
|
class_attribute :default_ransackable_attributes
|
|
9
|
-
|
|
9
|
+
# `position` is included so any `acts_as_list` model is sortable by it
|
|
10
|
+
# without each subclass having to opt in. Ransack ignores attributes
|
|
11
|
+
# the model doesn't actually expose, so this is a no-op for tables
|
|
12
|
+
# without a position column.
|
|
13
|
+
self.default_ransackable_attributes = %w[id name updated_at created_at position]
|
|
10
14
|
|
|
11
15
|
def self.ransackable_associations(*_args)
|
|
12
16
|
base = whitelisted_ransackable_associations || []
|
|
@@ -68,14 +68,15 @@ module Spree
|
|
|
68
68
|
false
|
|
69
69
|
end
|
|
70
70
|
|
|
71
|
+
# Stores against which this record should be indexed/removed. By default
|
|
72
|
+
# we use the record's own +store_id+ (Product, Customer, Order, …). The
|
|
73
|
+
# multi-store extension overrides this to fan out across every store the
|
|
74
|
+
# record is attached to. Falling back to +Spree::Store.default+ covers
|
|
75
|
+
# records like categories/taxonomies that don't have a +store_id+ column.
|
|
71
76
|
def store_ids_for_indexing
|
|
72
|
-
if respond_to?(:
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
[store_id].compact
|
|
76
|
-
else
|
|
77
|
-
Spree::Store.pluck(:id)
|
|
78
|
-
end
|
|
77
|
+
return [store_id] if respond_to?(:store_id) && store_id.present?
|
|
78
|
+
|
|
79
|
+
Array(Spree::Store.default&.id)
|
|
79
80
|
end
|
|
80
81
|
end
|
|
81
82
|
end
|
|
@@ -11,9 +11,18 @@ module Spree
|
|
|
11
11
|
encrypted_attributes = model_class.encrypted_attributes.presence || []
|
|
12
12
|
|
|
13
13
|
if encrypted_attributes.include?(attribute.to_sym)
|
|
14
|
-
|
|
14
|
+
# Encrypted columns can only be compared by equality — wildcard
|
|
15
|
+
# LIKE escapes would prevent the row from matching itself, so pass
|
|
16
|
+
# the raw query straight through.
|
|
17
|
+
model_class.arel_table[attribute.to_sym].eq(query.to_s.strip)
|
|
15
18
|
else
|
|
16
|
-
|
|
19
|
+
# Plain columns use case-insensitive LIKE. `sanitize_sql_like`
|
|
20
|
+
# escapes `_` and `%` so a query like `john_doe@example.com`
|
|
21
|
+
# doesn't have its underscore treated as a wildcard matching
|
|
22
|
+
# `john.doe@example.com`. Pass `\` as the ESCAPE character so
|
|
23
|
+
# SQLite/MySQL honor the escaping.
|
|
24
|
+
escaped = sanitize_query_for_search(query)
|
|
25
|
+
model_class.arel_table[attribute.to_sym].lower.matches("%#{escaped}%", '\\')
|
|
17
26
|
end
|
|
18
27
|
end
|
|
19
28
|
|