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.
Files changed (231) hide show
  1. checksums.yaml +4 -4
  2. data/app/helpers/spree/base_helper.rb +0 -82
  3. data/app/helpers/spree/currency_helper.rb +0 -12
  4. data/app/helpers/spree/products_helper.rb +0 -8
  5. data/app/jobs/spree/base_job.rb +18 -0
  6. data/app/jobs/spree/events/subscriber_job.rb +2 -1
  7. data/app/jobs/spree/exports/generate_job.rb +11 -0
  8. data/app/jobs/spree/images/save_from_url_job.rb +23 -8
  9. data/app/jobs/spree/imports/assign_tags_job.rb +11 -0
  10. data/app/jobs/spree/imports/base_job.rb +15 -0
  11. data/app/jobs/spree/imports/create_categories_job.rb +37 -0
  12. data/app/jobs/spree/imports/create_rows_job.rb +1 -3
  13. data/app/jobs/spree/imports/process_group_job.rb +8 -6
  14. data/app/jobs/spree/imports/process_rows_job.rb +1 -3
  15. data/app/jobs/spree/media/migrate_product_assets_job.rb +83 -0
  16. data/app/jobs/spree/products/refresh_metrics_job.rb +15 -4
  17. data/app/jobs/spree/reports/generate_job.rb +11 -0
  18. data/app/jobs/spree/search_provider/index_job.rb +5 -1
  19. data/app/jobs/spree/search_provider/remove_job.rb +4 -0
  20. data/app/jobs/spree/stock_reservations/expire_job.rb +11 -0
  21. data/app/models/concerns/spree/calculated_adjustments.rb +34 -1
  22. data/app/models/concerns/spree/display_on.rb +31 -0
  23. data/app/models/concerns/spree/metafields.rb +167 -5
  24. data/app/models/concerns/spree/preference_schema.rb +191 -0
  25. data/app/models/concerns/spree/prefixed_id.rb +94 -11
  26. data/app/models/concerns/spree/product_scopes.rb +36 -17
  27. data/app/models/concerns/spree/ransackable_attributes.rb +5 -1
  28. data/app/models/concerns/spree/search_indexable.rb +8 -7
  29. data/app/models/concerns/spree/searchable.rb +11 -2
  30. data/app/models/concerns/spree/stores/channels.rb +20 -0
  31. data/app/models/concerns/spree/stores/markets.rb +21 -5
  32. data/app/models/concerns/spree/typed_associations.rb +120 -0
  33. data/app/models/concerns/spree/user_methods.rb +71 -12
  34. data/app/models/spree/ability.rb +4 -117
  35. data/app/models/spree/api_key.rb +53 -0
  36. data/app/models/spree/asset.rb +28 -5
  37. data/app/models/spree/authentication/strategy_registry.rb +72 -0
  38. data/app/models/spree/base.rb +18 -1
  39. data/app/models/spree/channel.rb +159 -0
  40. data/app/models/spree/country.rb +2 -0
  41. data/app/models/spree/current.rb +5 -1
  42. data/app/models/spree/custom_field.rb +9 -0
  43. data/app/models/spree/custom_field_definition.rb +7 -0
  44. data/app/models/spree/customer_group.rb +8 -2
  45. data/app/models/spree/export.rb +30 -3
  46. data/app/models/spree/gateway.rb +25 -0
  47. data/app/models/spree/gift_card.rb +1 -1
  48. data/app/models/spree/gift_card_batch.rb +4 -1
  49. data/app/models/spree/import.rb +5 -0
  50. data/app/models/spree/import_row.rb +12 -0
  51. data/app/models/spree/line_item.rb +6 -1
  52. data/app/models/spree/market.rb +32 -1
  53. data/app/models/spree/metafield.rb +38 -0
  54. data/app/models/spree/metafield_definition.rb +29 -6
  55. data/app/models/spree/metafields/json.rb +10 -0
  56. data/app/models/spree/newsletter_subscriber.rb +19 -3
  57. data/app/models/spree/option_type.rb +48 -7
  58. data/app/models/spree/order/checkout.rb +3 -3
  59. data/app/models/spree/order.rb +102 -6
  60. data/app/models/spree/order_approval.rb +19 -0
  61. data/app/models/spree/order_cancellation.rb +19 -0
  62. data/app/models/spree/order_routing/has_strategy_preference.rb +28 -0
  63. data/app/models/spree/order_routing/rules/default_location.rb +16 -0
  64. data/app/models/spree/order_routing/rules/minimize_splits.rb +45 -0
  65. data/app/models/spree/order_routing/rules/preferred_location.rb +22 -0
  66. data/app/models/spree/order_routing/strategy/base.rb +47 -0
  67. data/app/models/spree/order_routing/strategy/legacy.rb +33 -0
  68. data/app/models/spree/order_routing/strategy/reducer.rb +68 -0
  69. data/app/models/spree/order_routing/strategy/rules.rb +81 -0
  70. data/app/models/spree/order_routing_rule.rb +75 -0
  71. data/app/models/spree/permission_sets/configuration_management.rb +16 -0
  72. data/app/models/spree/permission_sets/product_display.rb +2 -0
  73. data/app/models/spree/permission_sets/product_management.rb +2 -0
  74. data/app/models/spree/price.rb +14 -1
  75. data/app/models/spree/price_list.rb +129 -17
  76. data/app/models/spree/price_rule.rb +11 -1
  77. data/app/models/spree/price_rules/customer_group_rule.rb +15 -1
  78. data/app/models/spree/price_rules/market_rule.rb +16 -1
  79. data/app/models/spree/price_rules/user_rule.rb +21 -2
  80. data/app/models/spree/product/channels.rb +149 -0
  81. data/app/models/spree/product/legacy_multi_store_support.rb +40 -0
  82. data/app/models/spree/product/slugs.rb +1 -1
  83. data/app/models/spree/product.rb +172 -31
  84. data/app/models/spree/product_publication.rb +43 -0
  85. data/app/models/spree/promotion/actions/create_adjustment.rb +4 -0
  86. data/app/models/spree/promotion/actions/create_item_adjustments.rb +4 -0
  87. data/app/models/spree/promotion/actions/create_line_items.rb +32 -14
  88. data/app/models/spree/promotion/rules/country.rb +40 -18
  89. data/app/models/spree/promotion/rules/customer_group.rb +10 -1
  90. data/app/models/spree/promotion/rules/product.rb +4 -0
  91. data/app/models/spree/promotion/rules/taxon.rb +24 -1
  92. data/app/models/spree/promotion/rules/user.rb +21 -0
  93. data/app/models/spree/promotion/rules/user_logged_in.rb +6 -0
  94. data/app/models/spree/promotion.rb +22 -1
  95. data/app/models/spree/promotion_action.rb +17 -11
  96. data/app/models/spree/promotion_rule.rb +17 -18
  97. data/app/models/spree/search_provider/meilisearch.rb +12 -2
  98. data/app/models/spree/stock/availability_validator.rb +1 -1
  99. data/app/models/spree/stock/quantifier.rb +89 -9
  100. data/app/models/spree/stock_item.rb +36 -0
  101. data/app/models/spree/stock_location.rb +52 -0
  102. data/app/models/spree/stock_reservation.rb +38 -0
  103. data/app/models/spree/stock_reservations/insufficient_stock_error.rb +12 -0
  104. data/app/models/spree/store.rb +18 -72
  105. data/app/models/spree/store_credit.rb +0 -8
  106. data/app/models/spree/store_product.rb +11 -23
  107. data/app/models/spree/taxon.rb +0 -5
  108. data/app/models/spree/user_identity.rb +1 -2
  109. data/app/models/spree/variant.rb +132 -18
  110. data/app/models/spree/variant_media.rb +46 -0
  111. data/app/models/spree/webhook_delivery.rb +1 -1
  112. data/app/models/spree/webhook_endpoint.rb +24 -0
  113. data/app/models/spree/wished_item.rb +0 -13
  114. data/app/presenters/spree/csv/product_variant_presenter.rb +23 -3
  115. data/app/presenters/spree/search_provider/product_presenter.rb +11 -4
  116. data/app/presenters/spree/variant_presenter.rb +4 -3
  117. data/app/services/spree/addresses/update.rb +6 -8
  118. data/app/services/spree/cart/add_item.rb +10 -0
  119. data/app/services/spree/cart/empty.rb +2 -0
  120. data/app/services/spree/cart/remove_line_item.rb +10 -0
  121. data/app/services/spree/cart/remove_out_of_stock_items.rb +1 -1
  122. data/app/services/spree/cart/set_quantity.rb +10 -0
  123. data/app/services/spree/carts/complete.rb +1 -0
  124. data/app/services/spree/carts/create.rb +1 -0
  125. data/app/services/spree/carts/update.rb +18 -2
  126. data/app/services/spree/carts/upsert_items.rb +6 -6
  127. data/app/services/spree/imports/row_processors/customer.rb +4 -1
  128. data/app/services/spree/imports/row_processors/product_variant.rb +95 -57
  129. data/app/services/spree/newsletter/link_user.rb +53 -0
  130. data/app/services/spree/newsletter/subscribe.rb +31 -9
  131. data/app/services/spree/orders/approve.rb +27 -6
  132. data/app/services/spree/orders/build_shipments.rb +29 -0
  133. data/app/services/spree/orders/cancel.rb +34 -3
  134. data/app/services/spree/orders/complete.rb +53 -0
  135. data/app/services/spree/orders/create.rb +156 -0
  136. data/app/services/spree/orders/update.rb +51 -0
  137. data/app/services/spree/orders/upsert_items.rb +70 -0
  138. data/app/services/spree/prices/bulk_upsert.rb +201 -0
  139. data/app/services/spree/products/duplicator.rb +1 -1
  140. data/app/services/spree/products/prepare_nested_attributes.rb +2 -30
  141. data/app/services/spree/sample_data/loader.rb +30 -0
  142. data/app/services/spree/stock_reservations/extend.rb +19 -0
  143. data/app/services/spree/stock_reservations/release.rb +12 -0
  144. data/app/services/spree/stock_reservations/reserve.rb +103 -0
  145. data/app/services/spree/taxons/remove_products.rb +7 -1
  146. data/app/subscribers/spree/product_metrics_subscriber.rb +3 -7
  147. data/app/views/spree/invitation_mailer/invitation_email.html.erb +4 -0
  148. data/config/locales/en.yml +27 -10
  149. data/config/routes.rb +9 -0
  150. data/db/migrate/20260429000001_create_spree_order_cancellations.rb +25 -0
  151. data/db/migrate/20260429000002_create_spree_order_approvals.rb +22 -0
  152. data/db/migrate/20260429000003_add_status_to_spree_orders.rb +6 -0
  153. data/db/migrate/20260429000004_add_scopes_to_spree_api_keys.rb +11 -0
  154. data/db/migrate/20260501000001_create_spree_stock_reservations.rb +19 -0
  155. data/db/migrate/20260507162651_create_spree_variant_media.rb +23 -0
  156. data/db/migrate/20260508175303_add_pickup_to_spree_stock_locations.rb +12 -0
  157. data/db/migrate/20260508204040_create_spree_channels.rb +18 -0
  158. data/db/migrate/20260508204041_create_spree_order_routing_rules.rb +18 -0
  159. data/db/migrate/20260508204042_add_preferred_stock_location_to_spree_orders.rb +5 -0
  160. data/db/migrate/20260508204043_add_channel_id_to_spree_orders.rb +10 -0
  161. data/db/migrate/20260511000001_backfill_status_on_spree_orders.rb +57 -0
  162. data/db/migrate/20260515000001_add_store_id_to_spree_newsletter_subscribers.rb +25 -0
  163. data/db/migrate/20260529000001_add_unique_index_to_spree_price_rules.rb +41 -0
  164. data/db/migrate/20260529000002_add_unique_index_to_spree_promotion_rules.rb +37 -0
  165. data/db/migrate/20260601000001_create_spree_product_publications.rb +14 -0
  166. data/db/migrate/20260601000002_add_store_id_to_spree_products.rb +16 -0
  167. data/db/migrate/20260602000001_add_default_to_spree_channels.rb +14 -0
  168. data/db/sample_data/channels.rb +12 -0
  169. data/db/sample_data/orders.rb +1 -1
  170. data/db/sample_data/products.csv +212 -212
  171. data/lib/generators/spree/api_resource/api_resource_generator.rb +353 -0
  172. data/lib/generators/spree/api_resource/templates/admin_controller.rb.tt +23 -0
  173. data/lib/generators/spree/api_resource/templates/admin_controller_spec.rb.tt +59 -0
  174. data/lib/generators/spree/api_resource/templates/admin_serializer.rb.tt +11 -0
  175. data/lib/generators/spree/api_resource/templates/factory.rb.tt +26 -0
  176. data/lib/generators/spree/api_resource/templates/store_aliased_serializer.rb.tt +12 -0
  177. data/lib/generators/spree/api_resource/templates/store_controller.rb.tt +31 -0
  178. data/lib/generators/spree/api_resource/templates/store_controller_spec.rb.tt +61 -0
  179. data/lib/generators/spree/api_resource/templates/store_serializer.rb.tt +14 -0
  180. data/lib/generators/spree/controller_decorator/controller_decorator_generator.rb +66 -0
  181. data/lib/generators/spree/controller_decorator/templates/controller_decorator.rb.tt +25 -0
  182. data/lib/generators/spree/model/model_generator.rb +73 -7
  183. data/lib/generators/spree/model/templates/create_table_migration.rb.tt +40 -0
  184. data/lib/generators/spree/model/templates/model.rb.tt +28 -2
  185. data/lib/spree/core/configuration.rb +7 -0
  186. data/lib/spree/core/controller_helpers/auth.rb +0 -12
  187. data/lib/spree/core/controller_helpers/currency.rb +0 -17
  188. data/lib/spree/core/controller_helpers/order.rb +0 -19
  189. data/lib/spree/core/dependencies.rb +5 -2
  190. data/lib/spree/core/engine.rb +54 -7
  191. data/lib/spree/core/permission_configuration.rb +15 -0
  192. data/lib/spree/core/preferences/masking.rb +47 -0
  193. data/lib/spree/core/preferences/preferable_class_methods.rb +7 -1
  194. data/lib/spree/core/version.rb +1 -1
  195. data/lib/spree/core.rb +56 -5
  196. data/lib/spree/permitted_attributes.rb +9 -7
  197. data/lib/spree/testing_support/factories/address_factory.rb +16 -9
  198. data/lib/spree/testing_support/factories/api_key_factory.rb +1 -0
  199. data/lib/spree/testing_support/factories/channel_factory.rb +8 -0
  200. data/lib/spree/testing_support/factories/line_item_factory.rb +2 -8
  201. data/lib/spree/testing_support/factories/newsletter_subscriber_factory.rb +2 -0
  202. data/lib/spree/testing_support/factories/product_factory.rb +16 -7
  203. data/lib/spree/testing_support/factories/product_publication_factory.rb +6 -0
  204. data/lib/spree/testing_support/factories/refresh_token_factory.rb +15 -0
  205. data/lib/spree/testing_support/factories/stock_location_factory.rb +2 -2
  206. data/lib/spree/testing_support/factories/stock_reservation_factory.rb +31 -0
  207. data/lib/spree/testing_support/factories/variant_factory.rb +3 -3
  208. data/lib/spree/testing_support/order_walkthrough.rb +1 -1
  209. data/lib/spree/testing_support/store.rb +10 -0
  210. data/lib/spree/upgrades/5_4_to_5_5/manifest.yml +53 -0
  211. data/lib/tasks/channels.rake +94 -0
  212. data/lib/tasks/core.rake +1 -0
  213. data/lib/tasks/media.rake +27 -0
  214. data/lib/tasks/products.rake +4 -6
  215. data/lib/tasks/publications.rake +60 -0
  216. data/lib/tasks/upgrade.rake +211 -0
  217. metadata +83 -18
  218. data/app/finders/spree/variants/visible_finder.rb +0 -23
  219. data/app/paginators/spree/shared/paginate.rb +0 -30
  220. data/app/presenters/spree/filters/price_presenter.rb +0 -23
  221. data/app/presenters/spree/filters/price_range_presenter.rb +0 -30
  222. data/app/presenters/spree/filters/quantified_price_range_presenter.rb +0 -45
  223. data/app/presenters/spree/product_summary_presenter.rb +0 -27
  224. data/app/presenters/spree/variants/options_presenter.rb +0 -82
  225. data/app/services/spree/classifications/reposition.rb +0 -23
  226. data/app/sorters/spree/orders/sort.rb +0 -10
  227. data/lib/spree/core/controller_helpers/common.rb +0 -14
  228. data/lib/spree/core/token_generator.rb +0 -23
  229. data/lib/spree/database_type_utilities.rb +0 -22
  230. data/lib/spree/testing_support/bar_ability.rb +0 -14
  231. data/lib/spree/testing_support/factories/store_product_factory.rb +0 -6
@@ -0,0 +1,20 @@
1
+ module Spree
2
+ module Stores
3
+ module Channels
4
+ extend ActiveSupport::Concern
5
+
6
+ included do
7
+ has_many :channels, class_name: 'Spree::Channel', dependent: :destroy
8
+ has_one :default_channel, -> { default }, class_name: 'Spree::Channel'
9
+
10
+ after_create :ensure_default_channel
11
+ end
12
+
13
+ def ensure_default_channel
14
+ return if default_channel
15
+
16
+ channels.create!(name: 'Online Store', code: Spree::Channel::DEFAULT_CODE)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -5,12 +5,9 @@ module Spree
5
5
 
6
6
  included do
7
7
  has_many :markets, class_name: 'Spree::Market', dependent: :destroy
8
- end
8
+ has_one :default_market, -> { default }, class_name: 'Spree::Market'
9
9
 
10
- # Returns the default market for this store
11
- # @return [Spree::Market, nil]
12
- def default_market
13
- @default_market ||= Spree::Market.default_for_store(self)
10
+ after_create :ensure_default_market
14
11
  end
15
12
 
16
13
  # Returns the default country, derived from the default market
@@ -112,6 +109,25 @@ module Spree
112
109
 
113
110
  private
114
111
 
112
+ def ensure_default_market
113
+ return if markets.exists?
114
+
115
+ country = @default_country_for_market
116
+ return if country.blank?
117
+
118
+ iso_country = ISO3166::Country[country.iso]
119
+
120
+ Spree::Events.disable do
121
+ markets.create!(
122
+ name: country.name,
123
+ currency: iso_country&.currency_code || read_attribute(:default_currency) || 'USD',
124
+ default_locale: iso_country&.languages_official&.first || read_attribute(:default_locale) || 'en',
125
+ default: true,
126
+ countries: [country]
127
+ )
128
+ end
129
+ end
130
+
115
131
  def legacy_supported_currencies_list
116
132
  ([default_currency] + read_attribute(:supported_currencies).to_s.split(',')).uniq.map(&:to_s).map do |code|
117
133
  ::Money::Currency.find(code.strip)
@@ -0,0 +1,120 @@
1
+ module Spree
2
+ # Shared flat-payload writer for `has_many` associations whose rows
3
+ # are STI-typed and carry preferences/calculator metadata. Used by
4
+ # {Spree::Promotion} (rules, actions) and {Spree::PriceList} (rules).
5
+ #
6
+ # The wire shape is `[{ type:, id?, preferences: {...}, calculator?: {...}, *_ids?: [...] }]`
7
+ # and the reconciliation is: existing rows update by `id`, new rows
8
+ # build via `find_by_api_type`, missing rows destroy. Falls through
9
+ # to AR's standard writer when assigned model instances (Rails
10
+ # internals do this on association swap).
11
+ #
12
+ # New-record parents defer the work into an `@pending_<assoc>` ivar
13
+ # so child rows can FK to a persisted parent — the consumer model
14
+ # is responsible for flushing those in an `after_save`.
15
+ module TypedAssociations
16
+ extend ActiveSupport::Concern
17
+
18
+ private
19
+
20
+ # Routes a flat-payload assignment to either the deferred buffer
21
+ # (new record) or {#reconcile_typed_association}.
22
+ #
23
+ # @param association [Symbol] e.g. `:promotion_rules`, `:price_rules`
24
+ # @param rows [Array<Hash>, Array<Spree::Base>, nil]
25
+ # @return [void]
26
+ def assign_typed_association(association, rows)
27
+ first = Array(rows).first
28
+ return public_send(:"#{association}=", rows) if first.nil? || first.is_a?(Spree.base_class)
29
+
30
+ pending = Array(rows).map { |entry| entry.respond_to?(:to_h) ? entry.to_h.with_indifferent_access : entry.with_indifferent_access }
31
+
32
+ if new_record?
33
+ instance_variable_set(:"@pending_#{association}", pending)
34
+ return
35
+ end
36
+
37
+ reconcile_typed_association(association, pending)
38
+ end
39
+
40
+ # Flushes a pending payload from `@pending_<assoc>` (typically from
41
+ # an `after_save` hook) and clears the ivar.
42
+ #
43
+ # @param association [Symbol]
44
+ # @return [void]
45
+ def flush_pending_typed_association(association)
46
+ ivar = :"@pending_#{association}"
47
+ pending = instance_variable_get(ivar)
48
+ return unless pending
49
+
50
+ instance_variable_set(ivar, nil)
51
+ reconcile_typed_association(association, pending)
52
+ end
53
+
54
+ def reconcile_typed_association(association, rows)
55
+ collection = public_send(association)
56
+ kept_ids = rows.filter_map { |row| save_typed_association_row(collection, row) }
57
+ collection.where.not(id: kept_ids).destroy_all if kept_ids.any? || rows.empty?
58
+ end
59
+
60
+ def save_typed_association_row(collection, row)
61
+ record = find_or_build_typed_association_row(collection, row)
62
+ return nil unless record
63
+
64
+ preferences = row[:preferences]
65
+ calculator = row[:calculator]
66
+ attrs = row.except(:id, :type, :preferences, :calculator)
67
+
68
+ # `*_ids` mass-assignment on a new record builds join rows with a
69
+ # nil parent FK and fails `presence: true` on autosave. Defer
70
+ # those until after the row itself is persisted.
71
+ deferred_ids, scalar_attrs = attrs.partition { |k, _| record.new_record? && k.to_s.end_with?('_ids') }
72
+ record.assign_attributes(scalar_attrs.to_h) if scalar_attrs.any?
73
+
74
+ preferences&.each do |key, value|
75
+ next unless record.has_preference?(key.to_sym)
76
+
77
+ record.set_preference(key.to_sym, decode_preference_value(key, value))
78
+ end
79
+ record.assign_calculator_attributes(calculator) if calculator.present? && record.respond_to?(:assign_calculator_attributes)
80
+
81
+ # Always save — `record.changed?` doesn't reflect preferences
82
+ # (serialized hash) or calculator association changes.
83
+ record.save!
84
+
85
+ deferred_ids.each { |key, value| record.public_send("#{key}=", value) }
86
+ record.save! if record.changed?
87
+
88
+ record.id
89
+ end
90
+
91
+ # Decode `*_ids` array preferences (`customer_group_ids`, `user_ids`,
92
+ # …) from prefixed strings to raw PKs. Plain-scalar / non-id
93
+ # preferences pass through unchanged.
94
+ def decode_preference_value(key, value)
95
+ return value unless key.to_s.end_with?('_ids') && value.is_a?(Array)
96
+
97
+ value.map do |v|
98
+ Spree::PrefixedId.prefixed_id?(v) ? Spree::PrefixedId.decode_prefixed_id(v) : v
99
+ end
100
+ end
101
+
102
+ def find_or_build_typed_association_row(collection, row)
103
+ if row[:id].present?
104
+ id = Spree::PrefixedId.prefixed_id?(row[:id]) ? Spree::PrefixedId.decode_prefixed_id(row[:id]) : row[:id]
105
+ existing = collection.find { |r| r.id == id } || collection.find_by(id: id)
106
+ return existing if existing
107
+ end
108
+
109
+ klass = collection.proxy_association.klass.find_by_api_type(row[:type])
110
+ return nil unless klass
111
+
112
+ # `build(type:) + becomes(klass)` would leave a stale STI-parent stub
113
+ # in the collection target alongside the becomes'd copy; autosave then
114
+ # validates both under different identities and uniqueness checks fail.
115
+ record = klass.new
116
+ collection.proxy_association.add_to_target(record)
117
+ record
118
+ end
119
+ end
120
+ end
@@ -12,9 +12,35 @@ module Spree
12
12
  include Spree::Searchable
13
13
  include Spree::Publishable
14
14
 
15
+ # Opt-in escape hatch for admin-side customer creation. When the host
16
+ # `Spree::User` includes Devise's `:validatable`, password presence is
17
+ # enforced on every create; admin-created customers don't pick a
18
+ # password upfront — they claim the account later via password reset.
19
+ # The admin Customers controller flips `skip_password_validation = true`
20
+ # when no password was supplied; the storefront registration path never
21
+ # sets it, so customer self-signup still requires a password.
22
+ #
23
+ # Prepended (not just defined) so it sits above Devise's own
24
+ # `password_required?` and can fall through with `super` when the flag
25
+ # isn't set. On `LegacyUser` (gem default, no Devise) `super` raises
26
+ # NoMethodError if invoked — but the early return covers the only
27
+ # branch that ever reaches this method on that path, and `LegacyUser`
28
+ # doesn't call `password_required?` at all, so the override is a no-op
29
+ # there.
30
+ module SkipPasswordValidation
31
+ def password_required?
32
+ return false if skip_password_validation && encrypted_password.blank? && password.blank?
33
+ super
34
+ end
35
+ end
36
+
15
37
  included do
16
38
  has_prefix_id :cus # Stripe: cus_
17
39
 
40
+ attr_accessor :skip_password_validation
41
+
42
+ prepend SkipPasswordValidation
43
+
18
44
  # Enable lifecycle events for user models
19
45
  publishes_lifecycle_events
20
46
 
@@ -36,6 +62,10 @@ module Spree
36
62
  normalizes :email, :first_name, :last_name, with: ->(value) { value&.to_s&.squish&.presence }
37
63
  acts_as_taggable_on :tags
38
64
 
65
+ def tags=(tags)
66
+ self.tag_list = tags
67
+ end
68
+
39
69
  #
40
70
  # Associations
41
71
  #
@@ -75,30 +105,59 @@ module Spree
75
105
  end
76
106
 
77
107
  def self.search(query)
78
- sanitized_query = sanitize_query_for_search(query)
79
108
  return none if query.blank?
80
109
 
81
- name_conditions = []
82
-
83
- name_conditions << search_condition(self, :first_name, sanitized_query)
84
- name_conditions << search_condition(self, :last_name, sanitized_query)
110
+ # `search_condition` handles sanitization itself — it escapes LIKE
111
+ # wildcards for plain columns and compares encrypted columns by
112
+ # equality. Pass the raw query so it can branch correctly.
113
+ conditions = []
114
+ conditions << search_condition(self, :email, query)
115
+ conditions << search_condition(self, :first_name, query)
116
+ conditions << search_condition(self, :last_name, query)
85
117
 
86
- full_name = NameOfPerson::PersonName.full(sanitized_query)
118
+ full_name = NameOfPerson::PersonName.full(query.to_s.strip)
87
119
 
88
120
  if full_name.first.present? && full_name.last.present?
89
- name_conditions << search_condition(self, :first_name, full_name.first)
90
- name_conditions << search_condition(self, :last_name, full_name.last)
121
+ conditions << search_condition(self, :first_name, full_name.first)
122
+ conditions << search_condition(self, :last_name, full_name.last)
91
123
  end
92
124
 
93
- where(arel_table[:email].lower.eq(query.downcase)).or(where(name_conditions.reduce(:or)))
125
+ where(conditions.reduce(:or))
94
126
  end
95
127
 
96
128
  # Backward compatibility alias — remove in Spree 6.0
97
129
  def self.multi_search(query) = search(query)
98
130
 
99
- self.whitelisted_ransackable_associations = %w[bill_address ship_address addresses tags spree_roles]
100
- self.whitelisted_ransackable_attributes = %w[id email first_name last_name accepts_email_marketing]
101
- self.whitelisted_ransackable_scopes = %w[search multi_search]
131
+ self.whitelisted_ransackable_associations = %w[bill_address ship_address addresses tags spree_roles orders customer_groups]
132
+ self.whitelisted_ransackable_attributes = %w[id email first_name last_name phone accepts_email_marketing
133
+ created_at updated_at last_sign_in_at]
134
+ self.whitelisted_ransackable_scopes = %w[search multi_search with_min_total_spent]
135
+
136
+ scope :with_min_total_spent, ->(amount) {
137
+ joins(:orders).where(spree_orders: { state: 'complete' }).
138
+ group("#{table_name}.id").
139
+ having('SUM(spree_orders.total) >= ?', amount.to_d)
140
+ }
141
+
142
+ # Precomputes orders_count, total_spent, and last_order_completed_at via a
143
+ # single aggregate join so list endpoints avoid 4 queries per user. The
144
+ # values land on the user as virtual attributes.
145
+ scope :with_order_aggregates, -> {
146
+ order_table = Spree::Order.table_name
147
+ select(
148
+ "#{table_name}.*, " \
149
+ "COALESCE(orders_agg.orders_count, 0) AS orders_count, " \
150
+ "COALESCE(orders_agg.total_spent, 0) AS total_spent, " \
151
+ "orders_agg.last_order_completed_at AS last_order_completed_at"
152
+ ).joins(
153
+ "LEFT JOIN (" \
154
+ "SELECT user_id, COUNT(*) AS orders_count, SUM(total) AS total_spent, MAX(completed_at) AS last_order_completed_at " \
155
+ "FROM #{order_table} " \
156
+ "WHERE state = 'complete' AND user_id IS NOT NULL " \
157
+ "GROUP BY user_id" \
158
+ ") orders_agg ON orders_agg.user_id = #{table_name}.id"
159
+ )
160
+ }
102
161
 
103
162
  def self.with_email(query)
104
163
  where("#{table_name}.email LIKE ?", "%#{query}%")
@@ -1,8 +1,6 @@
1
- # Implementation class for Cancan gem. Instead of overriding this class, consider adding new permissions
2
- # using the special +register_ability+ method which allows extensions to add their own abilities.
3
- #
4
- # The preferred way to add permissions is now through permission sets. See Spree::PermissionSets::Base
5
- # for more details on creating custom permission sets.
1
+ # Implementation class for Cancan gem. Permissions are configured through
2
+ # permission sets see Spree::PermissionSets::Base for details on creating
3
+ # custom ones.
6
4
  #
7
5
  # @example Configuring role permissions
8
6
  # Spree.permissions.assign(:customer_service, [
@@ -17,37 +15,12 @@ module Spree
17
15
  class Ability
18
16
  include CanCan::Ability
19
17
 
20
- class_attribute :abilities
21
- self.abilities = Set.new
22
-
23
18
  # @return [Object] the current user
24
19
  attr_reader :user
25
20
 
26
21
  # @return [Spree::Store, nil] the current store
27
22
  attr_reader :store
28
23
 
29
- # Allows us to go beyond the standard cancan initialize method which makes it difficult for engines to
30
- # modify the default +Ability+ of an application. The +ability+ argument must be a class that includes
31
- # the +CanCan::Ability+ module. The registered ability should behave properly as a stand-alone class
32
- # and therefore should be easy to test in isolation.
33
- # @deprecated Use Spree::PermissionSets instead. Will be removed in Spree 5.5.
34
- def self.register_ability(ability)
35
- Spree::Deprecation.warn(
36
- 'Spree::Ability.register_ability is deprecated and will be removed in Spree 5.5. ' \
37
- 'Please use Spree::PermissionSets instead. See Spree::PermissionSets::Base for details.'
38
- )
39
- abilities.add(ability)
40
- end
41
-
42
- # @deprecated Use Spree::PermissionSets instead. Will be removed in Spree 5.5.
43
- def self.remove_ability(ability)
44
- Spree::Deprecation.warn(
45
- 'Spree::Ability.remove_ability is deprecated and will be removed in Spree 5.5. ' \
46
- 'Please use Spree::PermissionSets instead. See Spree::PermissionSets::Base for details.'
47
- )
48
- abilities.delete(ability)
49
- end
50
-
51
24
  def initialize(user, options = {})
52
25
  alias_cancan_delete_action
53
26
 
@@ -55,24 +28,10 @@ module Spree
55
28
  @store = options[:store] || Spree::Current.store
56
29
 
57
30
  apply_permissions_from_sets
58
-
59
- # Include any abilities registered by extensions, etc.
60
- # this is legacy behaviour and should be removed in Spree 5.0
61
- Ability.abilities.merge(abilities_to_register).each do |clazz|
62
- Spree::Deprecation.warn("Ability merging is deprecated and will be removed in Spree 5.5. Please use Permission Sets")
63
-
64
- merge clazz.new(@user)
65
- end
66
31
  end
67
32
 
68
33
  protected
69
34
 
70
- # you can override this method to register your abilities
71
- # this method has to return array of classes
72
- def abilities_to_register
73
- []
74
- end
75
-
76
35
  def alias_cancan_delete_action
77
36
  alias_action :delete, to: :destroy
78
37
  alias_action :create, :update, :destroy, to: :modify
@@ -82,13 +41,7 @@ module Spree
82
41
  def apply_permissions_from_sets
83
42
  role_names = determine_role_names
84
43
  permission_sets = Spree.permissions.permission_sets_for_roles(role_names)
85
-
86
- # If no permission sets are configured for the user's roles, use legacy behavior
87
- if permission_sets.empty?
88
- apply_legacy_permissions
89
- else
90
- activate_permission_sets(permission_sets)
91
- end
44
+ activate_permission_sets(permission_sets)
92
45
  end
93
46
 
94
47
  # Determines the role names for the current user.
@@ -121,71 +74,5 @@ module Spree
121
74
  permission_set.activate!
122
75
  end
123
76
  end
124
-
125
- # Legacy permission application for backward compatibility.
126
- # This is used when no permission sets are configured for the user's roles.
127
- def apply_legacy_permissions
128
- if @user.persisted? && @user.is_a?(Spree.admin_user_class) && @user.try(:spree_admin?, @store)
129
- apply_admin_permissions(@user, { store: @store })
130
- else
131
- apply_user_permissions(@user, { store: @store })
132
- end
133
-
134
- protect_admin_role
135
- end
136
-
137
- def apply_admin_permissions(_user, _options)
138
- Spree::Deprecation.warn("Ability#apply_admin_permissions is deprecated and will be removed in Spree 5.5. Please use Permission Sets")
139
- can :manage, :all
140
- cannot :cancel, Spree::Order
141
- can :cancel, Spree::Order, &:allow_cancel?
142
- cannot :destroy, Spree::Order
143
- can :destroy, Spree::Order, &:can_be_deleted?
144
- cannot [:edit, :update], Spree::RefundReason, mutable: false
145
- cannot [:edit, :update], Spree::ReimbursementType, mutable: false
146
- end
147
-
148
- def apply_user_permissions(user, _options)
149
- Spree::Deprecation.warn("Ability#apply_user_permissions is deprecated and will be removed in Spree 5.5. Please use Permission Sets")
150
-
151
- can :read, ::Spree::Country
152
- can :read, ::Spree::OptionType
153
- can :read, ::Spree::OptionValue
154
- can :create, ::Spree::Order
155
- can :show, ::Spree::Order do |order, token|
156
- order.user == user || order.token && token == order.token
157
- end
158
- can :update, ::Spree::Order do |order, token|
159
- !order.completed? && (order.user == user || order.token && token == order.token)
160
- end
161
- # Address management - only for persisted users with matching user_id
162
- can :manage, ::Spree::Address, user_id: user.id if user.persisted?
163
- can [:read, :destroy], ::Spree::CreditCard, user_id: user.id
164
- can :read, ::Spree::Product
165
- can :create, ::Spree.user_class
166
- can [:show, :update, :destroy], ::Spree.user_class, id: user.id
167
- can :read, ::Spree::State
168
- can :read, ::Spree::Store
169
- can :read, ::Spree::Taxon
170
- can :read, ::Spree::Taxonomy
171
- can :read, ::Spree::Variant
172
- can :read, ::Spree::Zone
173
- can :manage, ::Spree::Wishlist, user_id: user.id
174
- can :show, ::Spree::Wishlist do |wishlist|
175
- wishlist.user == user || wishlist.is_private == false
176
- end
177
- can [:create, :update, :destroy], ::Spree::WishedItem do |wished_item|
178
- wished_item.wishlist.user == user
179
- end
180
- can :accept, Spree::Invitation, invitee_id: [user.id, nil], invitee_type: user.class.name, status: 'pending'
181
- can :show, ::Spree::DigitalLink do |digital_link, token|
182
- digital_link.token == token
183
- end
184
- can :read, ::Spree::Policy
185
- end
186
-
187
- def protect_admin_role
188
- cannot [:update, :destroy], ::Spree::Role, name: ['admin']
189
- end
190
77
  end
191
78
  end
@@ -6,6 +6,36 @@ module Spree
6
6
  PREFIXES = { 'publishable' => 'pk_', 'secret' => 'sk_' }.freeze
7
7
  TOKEN_LENGTH = 24
8
8
 
9
+ # Admin API authorization scopes. Granted to secret keys at creation; checked
10
+ # by ScopedAuthorization on every admin request. See
11
+ # docs/plans/5.5-admin-api-key-scopes.md for the full design.
12
+ SCOPES = %w[
13
+ read_orders write_orders
14
+ read_products write_products
15
+ read_customers write_customers
16
+ read_payments write_payments
17
+ read_fulfillments write_fulfillments
18
+ read_refunds write_refunds
19
+ read_gift_cards write_gift_cards
20
+ read_store_credits write_store_credits
21
+ read_stock write_stock
22
+ read_categories write_categories
23
+ read_custom_field_definitions write_custom_field_definitions
24
+ read_exports write_exports
25
+ read_settings write_settings
26
+ read_webhooks write_webhooks
27
+ read_dashboard
28
+ read_all write_all
29
+ ].freeze
30
+
31
+ # Scopes are stored in a JSON column (jsonb on PostgreSQL, json elsewhere).
32
+ # The DB driver handles array <-> JSON conversion; no `serialize` needed.
33
+ attribute :scopes, default: []
34
+
35
+ def scopes=(value)
36
+ super(Array(value).map(&:to_s).reject(&:blank?))
37
+ end
38
+
9
39
  # Returns the raw token value. For publishable keys this is the persisted
10
40
  # +token+ column. For secret keys it is only available in memory immediately
11
41
  # after creation (not persisted).
@@ -25,6 +55,8 @@ module Spree
25
55
  validates :token_digest, presence: true, uniqueness: true, if: :secret?
26
56
  validates :token_prefix, presence: true, if: :secret?
27
57
  validates :store, presence: true
58
+ validates :scopes, presence: true, if: :secret?
59
+ validate :validate_known_scopes, if: :secret?
28
60
 
29
61
  before_validation :generate_token, on: :create
30
62
 
@@ -83,8 +115,29 @@ module Spree
83
115
  update!(revoked_at: Time.current, revoked_by: user)
84
116
  end
85
117
 
118
+ # Whether this key carries the given scope. `write_*` implies the matching
119
+ # `read_*`; `read_all` / `write_all` aliases expand to every read / read+write
120
+ # scope respectively.
121
+ #
122
+ # @param scope [String]
123
+ # @return [Boolean]
124
+ def has_scope?(scope)
125
+ scope = scope.to_s
126
+ return true if scopes.include?(scope)
127
+ return true if scope.start_with?('read_') && scopes.include?("write_#{scope.delete_prefix('read_')}")
128
+ return true if scopes.include?('write_all')
129
+ return true if scope.start_with?('read_') && scopes.include?('read_all')
130
+
131
+ false
132
+ end
133
+
86
134
  private
87
135
 
136
+ def validate_known_scopes
137
+ invalid = scopes - SCOPES
138
+ errors.add(:scopes, "contains unknown scopes: #{invalid.join(', ')}") if invalid.any?
139
+ end
140
+
88
141
  # Generates the token on creation. For publishable keys, stores the raw token
89
142
  # in the +token+ column. For secret keys, computes an HMAC-SHA256 digest stored
90
143
  # in +token_digest+, saves the first 12 characters as +token_prefix+ for display,
@@ -28,6 +28,9 @@ module Spree
28
28
  after_initialize { self.media_type ||= 'image' }
29
29
 
30
30
  belongs_to :viewable, polymorphic: true, touch: true
31
+ has_many :variant_media, class_name: 'Spree::VariantMedia', foreign_key: :media_id,
32
+ dependent: :destroy, inverse_of: :asset
33
+ has_many :variants, through: :variant_media, source: :variant, class_name: 'Spree::Variant'
31
34
  acts_as_list scope: [:viewable_id, :viewable_type]
32
35
 
33
36
  delegate :key, :attached?, :variant, :variable?, :blob, :filename, :variation, to: :attachment
@@ -91,6 +94,15 @@ module Spree
91
94
  end
92
95
  end
93
96
 
97
+ # Accepts prefixed IDs ("variant_abc") or raw IDs from admin forms.
98
+ # Variants from a different product are silently dropped — the security
99
+ # boundary against form tampering.
100
+ def variant_ids=(ids)
101
+ return if viewable_type != 'Spree::Product' || product.blank?
102
+
103
+ super(Spree::VariantMedia.resolve_variant_ids(product, ids || []))
104
+ end
105
+
94
106
  def focal_point
95
107
  return nil if focal_point_x.nil? || focal_point_y.nil?
96
108
 
@@ -142,14 +154,21 @@ module Spree
142
154
  private
143
155
 
144
156
  def touch_product_variants
145
- viewable.product.variants.touch_all
157
+ product = viewable.is_a?(Spree::Product) ? viewable : viewable.product
158
+ product.variants.touch_all
146
159
  end
147
160
 
148
161
  def should_touch_product_variants?
149
- viewable.is_a?(Spree::Variant) &&
150
- viewable.is_master? &&
151
- viewable.product.has_variants? &&
152
- saved_change_to_position?
162
+ return false unless saved_change_to_position?
163
+
164
+ case viewable
165
+ when Spree::Product
166
+ true
167
+ when Spree::Variant
168
+ viewable.is_master? && viewable.product.has_variants?
169
+ else
170
+ false
171
+ end
153
172
  end
154
173
 
155
174
  def increment_viewable_media_count
@@ -179,6 +198,10 @@ module Spree
179
198
  viewable.product.update_thumbnail!
180
199
  when Spree::Product
181
200
  viewable.update_thumbnail!
201
+ # Linked variants resolve their own thumbnail through gallery_media,
202
+ # which sorts by this asset's product-level position. Reorders or
203
+ # destroys here can change a linked variant's first asset.
204
+ variants.find_each(&:update_thumbnail!)
182
205
  end
183
206
  end
184
207
 
@@ -0,0 +1,72 @@
1
+ require 'forwardable'
2
+
3
+ module Spree
4
+ module Authentication
5
+ # Keyed registry of authentication strategy classes for the Store and Admin APIs.
6
+ #
7
+ # Strategies are dispatched by the `provider` value the client sends to the auth
8
+ # endpoint, so the registry is a key → class map. The `:email` key is reserved for
9
+ # the built-in {Spree::Authentication::Strategies::EmailPasswordStrategy}; integrators
10
+ # can override it by adding a different class under the same key.
11
+ #
12
+ # @example Registering a custom provider
13
+ # Spree.store_authentication_strategies.add(:auth0, MyApp::Auth::Auth0Strategy)
14
+ #
15
+ # @example Removing a provider
16
+ # Spree.store_authentication_strategies.remove(:email)
17
+ #
18
+ # @example Reading a strategy class
19
+ # Spree.store_authentication_strategies[:email]
20
+ class StrategyRegistry
21
+ extend Forwardable
22
+ include Enumerable
23
+
24
+ def_delegators :@strategies, :keys, :values, :each
25
+
26
+ def initialize(strategies = {})
27
+ @strategies = {}
28
+ strategies.each { |key, klass| add(key, klass) }
29
+ end
30
+
31
+ # Register a strategy class under the given provider key. Overwrites any
32
+ # existing entry for that key.
33
+ #
34
+ # @param key [Symbol, String] provider identifier sent by the client
35
+ # @param strategy_class [Class] strategy class (typically a subclass of
36
+ # {Spree::Authentication::Strategies::BaseStrategy})
37
+ # @return [Class] the registered class
38
+ def add(key, strategy_class)
39
+ @strategies[key.to_sym] = strategy_class
40
+ end
41
+
42
+ # Unregister a strategy. Idempotent — returns `nil` if the key is not present.
43
+ #
44
+ # @param key [Symbol, String]
45
+ # @return [Class, nil] the removed class, or nil if no such key
46
+ def remove(key)
47
+ @strategies.delete(key.to_sym)
48
+ end
49
+
50
+ # Look up a registered strategy class.
51
+ #
52
+ # @param key [Symbol, String] provider identifier
53
+ # @return [Class, nil] the registered strategy class, or nil if no such key
54
+ def [](key)
55
+ @strategies[key.to_sym]
56
+ end
57
+
58
+ # Whether a strategy is registered under the given provider key.
59
+ #
60
+ # @param key [Symbol, String]
61
+ # @return [Boolean]
62
+ def key?(key)
63
+ @strategies.key?(key.to_sym)
64
+ end
65
+
66
+ # @return [Hash{Symbol => Class}] a shallow copy of the underlying map
67
+ def to_h
68
+ @strategies.dup
69
+ end
70
+ end
71
+ end
72
+ end