spree_core 6.0.0.beta1 → 6.0.0.beta2

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/app/jobs/spree/sample_data/load_job.rb +13 -0
  3. data/app/models/concerns/spree/acted_by.rb +210 -0
  4. data/app/models/concerns/spree/actor.rb +51 -0
  5. data/app/models/concerns/spree/admin_user_methods.rb +48 -17
  6. data/app/models/concerns/spree/purchase/payment_processing.rb +1 -1
  7. data/app/models/spree/api_key.rb +5 -3
  8. data/app/models/spree/catalog.rb +50 -13
  9. data/app/models/spree/claim.rb +2 -1
  10. data/app/models/spree/current.rb +41 -13
  11. data/app/models/spree/exchange.rb +2 -1
  12. data/app/models/spree/line_item.rb +1 -1
  13. data/app/models/spree/order.rb +54 -6
  14. data/app/models/spree/pricing_provider/internal.rb +37 -15
  15. data/app/models/spree/refund.rb +2 -1
  16. data/app/models/spree/return.rb +2 -1
  17. data/app/models/spree/stock_receipt.rb +7 -2
  18. data/app/models/spree/store.rb +4 -0
  19. data/app/services/spree/orders/approve.rb +3 -2
  20. data/app/workflows/spree/orders/cancel.rb +3 -2
  21. data/app/workflows/spree/refunds/create.rb +1 -1
  22. data/app/workflows/spree/returns/approve.rb +1 -1
  23. data/app/workflows/spree/returns/create.rb +2 -2
  24. data/app/workflows/spree/returns/refund.rb +1 -1
  25. data/config/locales/en.yml +1 -0
  26. data/db/migrate/20260916000001_order_operation_actors_polymorphic.rb +30 -0
  27. data/lib/generators/spree/api_resource/api_resource_generator.rb +63 -5
  28. data/lib/generators/spree/api_resource/templates/admin_controller.rb.tt +5 -0
  29. data/lib/generators/spree/api_resource/templates/admin_controller_spec.rb.tt +3 -0
  30. data/lib/generators/spree/api_resource/templates/store_controller_spec.rb.tt +4 -1
  31. data/lib/generators/spree/model/model_generator.rb +40 -1
  32. data/lib/generators/spree/model/templates/create_table_migration.rb.tt +9 -0
  33. data/lib/generators/spree/model/templates/model.rb.tt +10 -4
  34. data/lib/spree/core/configuration.rb +25 -25
  35. data/lib/spree/core/engine.rb +27 -0
  36. data/lib/spree/core/preferences/runtime_configuration.rb +76 -2
  37. data/lib/spree/core/version.rb +1 -1
  38. data/lib/spree/core.rb +27 -0
  39. data/lib/spree/testing_support/factories/user_factory.rb +0 -1
  40. data/lib/tasks/backfill_actor_types.rake +35 -0
  41. data/lib/tasks/store_settings.rake +1 -0
  42. metadata +14 -9
  43. /data/{app/models → lib}/spree/checkout/default_requirements.rb +0 -0
  44. /data/{app/models → lib}/spree/checkout/registry.rb +0 -0
  45. /data/{app/models → lib}/spree/checkout/requirement.rb +0 -0
  46. /data/{app/models → lib}/spree/checkout/requirements.rb +0 -0
  47. /data/{app/models → lib}/spree/checkout/step.rb +0 -0
@@ -17,7 +17,7 @@ module Spree
17
17
  belongs_to :variant, -> { with_deleted }, class_name: 'Spree::Variant'
18
18
  end
19
19
  belongs_to :tax_category, -> { with_deleted }, class_name: 'Spree::TaxCategory', optional: true
20
- belongs_to :price_list, class_name: 'Spree::PriceList', optional: true
20
+ belongs_to :price_list, -> { with_deleted }, class_name: 'Spree::PriceList', optional: true
21
21
  # Snapshotted from the variant when the line is added — see copy_seller.
22
22
  # Nil is the operator's own first-party item.
23
23
  belongs_to :seller, class_name: 'Spree::Seller', optional: true
@@ -23,6 +23,7 @@ module Spree
23
23
 
24
24
  extend Spree::DisplayMoney
25
25
 
26
+ include Spree::ActedBy
26
27
  include Spree::SingleStoreResource
27
28
  include Spree::SanitizableRichText
28
29
  include Spree::Purchase::Channel
@@ -91,6 +92,54 @@ module Spree
91
92
  order
92
93
  end
93
94
 
95
+ # Checkout-step introspection belongs to Spree::Cart
96
+ # ({Spree::Purchase::CheckoutSteps}) since 6.0 — an order is past checkout
97
+ # by definition. These bridges answer for an upgrader reaching for the
98
+ # Spree 5 API on an order, rather than raising NoMethodError.
99
+ #
100
+ # The step list is data-driven, so it stays accurate here: the registry
101
+ # computes it from the record's own predicates, all of which Order carries.
102
+ # Where the checkout stands within that list is not, which is why the
103
+ # readers below report a finished checkout.
104
+
105
+ # @deprecated Checkout steps belong to Spree::Cart; removed in 6.1.
106
+ def checkout_steps
107
+ Spree::Deprecation.warn('Spree::Order#checkout_steps is deprecated and will be removed in Spree 6.1. Checkout steps belong to Spree::Cart — ask the cart before it is completed.')
108
+ Spree::Checkout::Registry.step_names_for(self)
109
+ end
110
+
111
+ # @deprecated See {#checkout_steps}; removed in 6.1.
112
+ def has_checkout_step?(step)
113
+ Spree::Deprecation.warn('Spree::Order#has_checkout_step? is deprecated and will be removed in Spree 6.1. Checkout steps belong to Spree::Cart — ask the cart before it is completed.')
114
+ step.present? && Spree::Checkout::Registry.step_names_for(self).include?(step.to_s)
115
+ end
116
+
117
+ # @deprecated See {#checkout_steps}; removed in 6.1.
118
+ def checkout_step_index(step)
119
+ Spree::Deprecation.warn('Spree::Order#checkout_step_index is deprecated and will be removed in Spree 6.1. Checkout steps belong to Spree::Cart — ask the cart before it is completed.')
120
+ Spree::Checkout::Registry.step_names_for(self).index(step).to_i
121
+ end
122
+
123
+ # @deprecated See {#checkout_steps}; removed in 6.1. An order has no
124
+ # outstanding step, so this is always 'complete'.
125
+ def current_checkout_step
126
+ Spree::Deprecation.warn("Spree::Order#current_checkout_step is deprecated and will be removed in Spree 6.1. An order is past checkout, so this is always 'complete'.")
127
+ 'complete'
128
+ end
129
+
130
+ # @deprecated See {#checkout_steps}; removed in 6.1.
131
+ def final_checkout_step
132
+ Spree::Deprecation.warn('Spree::Order#final_checkout_step is deprecated and will be removed in Spree 6.1. Checkout steps belong to Spree::Cart — ask the cart before it is completed.')
133
+ Spree::Checkout::Registry.step_names_for(self).reject { |step| step == 'complete' }.last || 'address'
134
+ end
135
+
136
+ # @deprecated See {#checkout_steps}; removed in 6.1. Every step is behind
137
+ # an order, so this is the whole list bar 'complete'.
138
+ def completed_checkout_steps
139
+ Spree::Deprecation.warn("Spree::Order#completed_checkout_steps is deprecated and will be removed in Spree 6.1. An order is past checkout, so every step bar 'complete' is behind it.")
140
+ Spree::Checkout::Registry.step_names_for(self).reject { |step| step == 'complete' }
141
+ end
142
+
94
143
  # Standardized column names (renamed in 6.0); legacy readers stay as
95
144
  # aliases one release.
96
145
  alias_attribute :promo_total, :discount_total
@@ -169,9 +218,7 @@ module Spree
169
218
  # Whose sale this is. Nil on the operator's own goods, including the
170
219
  # first-party child of a mixed marketplace checkout.
171
220
  belongs_to :seller, class_name: 'Spree::Seller', optional: true
172
- belongs_to :created_by, class_name: "::#{Spree.admin_user_class}", optional: true
173
- belongs_to :approver, class_name: "::#{Spree.admin_user_class}", optional: true
174
- belongs_to :canceler, class_name: "::#{Spree.admin_user_class}", optional: true
221
+ acted_by :created_by, :approver, :canceler
175
222
  belongs_to :cancel_reason, class_name: 'Spree::OrderCancellationReason', optional: true, inverse_of: :orders
176
223
 
177
224
  belongs_to :preferred_stock_location, class_name: 'Spree::StockLocation', optional: true
@@ -1089,10 +1136,11 @@ module Spree
1089
1136
  # Approves the order and records the approver.
1090
1137
  # Delegates to {Spree::Orders::Approve} service.
1091
1138
  #
1092
- # @param user [Spree.customer_class, nil] the user who approved the order
1139
+ # @param actor [Object, nil] who approved it — an admin user or an API
1140
+ # key (see Spree.actor_classes)
1093
1141
  # @return [Spree::ServiceModule::Result]
1094
- def approved_by(user = nil)
1095
- Spree.order_approve_service.call(order: self, approver: user)
1142
+ def approved_by(actor = nil)
1143
+ Spree.order_approve_service.call(order: self, approver: actor)
1096
1144
  end
1097
1145
 
1098
1146
  def approved?
@@ -45,7 +45,12 @@ module Spree
45
45
  # back to base.
46
46
  # @return [Spree::Price]
47
47
  def find_price_from_lists
48
- (catalog_price_lists + applicable_price_lists).each do |price_list|
48
+ catalog_price_list_groups.each do |price_lists|
49
+ price = best_price_among(price_lists)
50
+ return price if price
51
+ end
52
+
53
+ applicable_price_lists.each do |price_list|
49
54
  price = find_price_for_list(price_list)
50
55
  return price if price
51
56
  end
@@ -53,12 +58,25 @@ module Spree
53
58
  nil
54
59
  end
55
60
 
56
- # Price lists reached through the buyer's effective catalogs, in
57
- # catalog resolution order the company subtree, or failing that the
58
- # customer's groups, or failing that the channel's default catalog.
59
- # The same chain visibility uses, and its steps are alternatives: a
60
- # company buyer is priced by their company's agreement and never
61
- # also by their customer group's ({Spree::Catalog.for_context}).
61
+ # The cheapest price one node's lists give, nil when none prices this
62
+ # variant. Catalogs sharing a node rank equally, so taking the first to
63
+ # answer charged a company from whichever sorted first rather than the
64
+ # cheaper (docs/plans/6.0-b2b-companies-and-catalogs.md). Equal amounts
65
+ # keep list order, so the answer is stable.
66
+ #
67
+ # @param price_lists [Array<Spree::PriceList>]
68
+ # @return [Spree::Price, nil]
69
+ def best_price_among(price_lists)
70
+ price_lists.filter_map { |price_list| find_price_for_list(price_list) }.min_by(&:amount)
71
+ end
72
+
73
+ # Price lists reached through the buyer's effective catalogs, grouped
74
+ # by the node that assigns them and ordered nearest node first — the
75
+ # company subtree, or failing that the customer's groups, or failing
76
+ # that the channel's default catalog. The same chain visibility uses,
77
+ # and its steps are alternatives: a company buyer is priced by their
78
+ # company's agreement and never also by their customer group's
79
+ # ({Spree::Catalog.groups_for_context}).
62
80
  #
63
81
  # A catalog-attached list applies because the catalog applies: its
64
82
  # audience rules are not consulted, since the assignment already
@@ -68,10 +86,13 @@ module Spree
68
86
  # volume pricing is (docs/plans/6.0-price-list-automatic-pricing.md).
69
87
  # Status and dates gate the list as before.
70
88
  #
71
- # @return [Array<Spree::PriceList>]
72
- def catalog_price_lists
73
- @catalog_price_lists ||= catalogs_for_context.filter_map(&:price_list).uniq.select do |price_list|
74
- price_list.currently_active? && price_list.contextual_rules_applicable?(context)
89
+ # @return [Array<Array<Spree::PriceList>>]
90
+ def catalog_price_list_groups
91
+ @catalog_price_list_groups ||= catalog_groups_for_context.filter_map do |catalogs|
92
+ lists = catalogs.filter_map(&:price_list).select do |price_list|
93
+ price_list.currently_active? && price_list.contextual_rules_applicable?(context)
94
+ end
95
+ lists.presence
75
96
  end
76
97
  end
77
98
 
@@ -89,11 +110,12 @@ module Spree
89
110
  # Catalogs that apply to this buyer, through the one entry point
90
111
  # visibility and quantity terms also use — what a buyer is shown, what
91
112
  # they pay and how much they must take have to come from the same
92
- # agreement. Reuses the request-scoped set for the current store, so a
113
+ # agreement. Grouped by assigning node, since that is where precedence
114
+ # lives. Reuses the request-scoped set for the current store, so a
93
115
  # listing does not re-resolve the same buyer per variant.
94
- # @return [Array<Spree::Catalog>]
95
- def catalogs_for_context
96
- Spree::Catalog.for_buyer(
116
+ # @return [Array<Array<Spree::Catalog>>]
117
+ def catalog_groups_for_context
118
+ Spree::Catalog.groups_for_buyer(
97
119
  store: context.store, customer: context.user,
98
120
  company: context.company, channel: context.channel
99
121
  )
@@ -2,6 +2,7 @@ module Spree
2
2
  class Refund < Spree.base_class
3
3
  has_prefix_id :re # Stripe: re_
4
4
 
5
+ include Spree::ActedBy
5
6
  include Spree::HasCustomFields
6
7
  include Spree::Metadata
7
8
  include Spree::InstrumentsGatewayCalls
@@ -18,7 +19,7 @@ module Spree
18
19
  # payment covers several and only one of them is being refunded.
19
20
  belongs_to :order, class_name: 'Spree::Order', optional: true, inverse_of: :refunds
20
21
  belongs_to :reason, class_name: 'Spree::RefundReason', foreign_key: :refund_reason_id
21
- belongs_to :refunder, class_name: Spree.admin_user_class.to_s, optional: true
22
+ acted_by :refunder
22
23
  # What triggered this refund — a Spree::Return today, later an Exchange
23
24
  # or Claim; nil for a manual refund. Deliberately polymorphic: the set is
24
25
  # small and closed, and refunds are never bulk-queried in a hot path.
@@ -14,6 +14,7 @@ module Spree
14
14
  has_prefix_id :ret
15
15
 
16
16
  has_spree_number prefix: 'RET'
17
+ include Spree::ActedBy
17
18
  include Spree::NumberIdentifier
18
19
  include Spree::SingleStoreResource
19
20
  include Spree::HasStatus
@@ -31,7 +32,7 @@ module Spree
31
32
  belongs_to :reason, class_name: 'Spree::ReturnReason', optional: true, inverse_of: :returns
32
33
  # Staff only. Customer-initiated returns leave this nil — the requester
33
34
  # is always order.customer, so no second association is needed.
34
- belongs_to :created_by, class_name: Spree.admin_user_class.to_s, optional: true
35
+ acted_by :created_by
35
36
 
36
37
  has_many :return_line_items, class_name: 'Spree::ReturnLineItem',
37
38
  dependent: :destroy, inverse_of: :return
@@ -9,6 +9,7 @@ module Spree
9
9
  class StockReceipt < Spree.base_class
10
10
  has_prefix_id :sr
11
11
 
12
+ include Spree::ActedBy
12
13
  include Spree::SingleStoreResource
13
14
  has_spree_number prefix: 'SR'
14
15
  include Spree::NumberIdentifier
@@ -17,7 +18,7 @@ module Spree
17
18
  publishes_lifecycle_events
18
19
 
19
20
  belongs_to :receivable, polymorphic: true, inverse_of: :stock_receipts
20
- belongs_to :received_by, class_name: Spree.admin_user_class.to_s, optional: true
21
+ acted_by :received_by
21
22
 
22
23
  has_many :items, class_name: 'Spree::StockReceiptItem', inverse_of: :stock_receipt,
23
24
  dependent: :destroy
@@ -29,8 +30,12 @@ module Spree
29
30
 
30
31
  normalizes :reference, with: ->(value) { value.strip.presence }
31
32
 
33
+ # `received_by_type` sits beside its id because ids are per-table: filtering
34
+ # on the id alone would match the admin user and the API key that happen to
35
+ # share that number.
32
36
  self.whitelisted_ransackable_attributes = %w[number reference received_at receivable_type
33
- receivable_id received_by_id created_at]
37
+ receivable_id received_by_id received_by_type
38
+ created_at]
34
39
 
35
40
  # @return [Integer] units this delivery put on the shelf
36
41
  def quantity_accepted_total
@@ -95,6 +95,10 @@ module Spree
95
95
  # e.g. "https://myshop.com" — see #storefront_url for the fallback chain.
96
96
  preference :storefront_url, :string
97
97
  preference :special_instructions_enabled, :boolean, default: false
98
+ # Advertises a confirm/review step for every checkout, even when no
99
+ # payment method asks for one. Payment methods that require confirmation
100
+ # get it regardless of this setting.
101
+ preference :always_include_confirm_step, :boolean, default: false
98
102
  preference :stock_reservation_ttl_minutes, :integer, default: 10
99
103
  # Store-wide default for when a customer is charged rather than only
100
104
  # authorized. A payment method's own capture_method wins when set.
@@ -6,7 +6,8 @@ module Spree
6
6
  # Approves an order: clears the risk flag and records who approved it and when.
7
7
  #
8
8
  # @param order [Spree::Order]
9
- # @param approver [Object, nil] the user/admin who approved
9
+ # @param approver [Object, nil] who approved it — an admin user or an
10
+ # API key (see Spree.actor_classes)
10
11
  # @param level [String, nil] deprecated and ignored — removed in Spree 6.1
11
12
  # @param note [String, nil] deprecated and ignored — removed in Spree 6.1
12
13
  # @return [Spree::ServiceModule::Result]
@@ -16,7 +17,7 @@ module Spree
16
17
  end
17
18
 
18
19
  changes = { considered_risky: false, approved_at: Time.current }
19
- changes[:approver_id] = approver.id if approver.present?
20
+ changes.merge!(Spree::ActedBy.columns_for(:approver, approver)) if approver.present?
20
21
  order.update_columns(changes)
21
22
 
22
23
  order.publish_event('order.approved')
@@ -11,7 +11,8 @@ module Spree
11
11
  hooks :before_cancel, :after_cancel
12
12
 
13
13
  # @param order [Spree::Order]
14
- # @param canceler [Object, nil] the user/admin who initiated the cancellation
14
+ # @param canceler [Object, nil] who initiated it — an admin user or an
15
+ # API key (see Spree.actor_classes)
15
16
  # @param canceled_at [Time, nil] timestamp (defaults to Time.current)
16
17
  # @param reason [Spree::OrderCancellationReason, nil] the merchant's own
17
18
  # vocabulary; must belong to the order's store
@@ -102,7 +103,7 @@ module Spree
102
103
 
103
104
  def mark_canceled
104
105
  changes = { status: 'canceled', canceled_at: @decided_at, cancel_reason_id: reason&.id, cancel_note: note }
105
- changes[:canceler_id] = canceler.id if canceler.present?
106
+ changes.merge!(Spree::ActedBy.columns_for(:canceler, canceler)) if canceler.present?
106
107
  order.update_columns(changes)
107
108
  end
108
109
 
@@ -19,7 +19,7 @@ module Spree
19
19
  # creditable balance
20
20
  # @param reason [Spree::RefundReason, nil] defaults to the store's
21
21
  # return-processing reason
22
- # @param refunder [Object, nil] the admin issuing the refund
22
+ # @param refunder [Object, nil] who is issuing it (see Spree.actor_classes)
23
23
  # @param originator [Object, nil] what triggered the refund — a
24
24
  # Spree::Return, Exchange or Claim; nil for a manual refund
25
25
  # @param order [Spree::Order, nil] which order is being put right.
@@ -13,7 +13,7 @@ module Spree
13
13
  hooks :validate, :after_approve
14
14
 
15
15
  # @param return_record [Spree::Return]
16
- # @param approver [Object, nil] the admin approving it
16
+ # @param approver [Object, nil] who is approving it (see Spree.actor_classes)
17
17
  def perform(return_record:, approver: nil)
18
18
  super
19
19
 
@@ -18,8 +18,8 @@ module Spree
18
18
  # back to; defaults to the location that shipped them
19
19
  # @param reason [Spree::ReturnReason, nil]
20
20
  # @param memo [String, nil] customer- or staff-supplied note
21
- # @param created_by [Object, nil] the admin opening it; nil for
22
- # customer self-service
21
+ # @param created_by [Object, nil] who is opening it (see
22
+ # Spree.actor_classes); nil for customer self-service
23
23
  def perform(order:, items:, stock_location: nil, reason: nil, memo: nil, created_by: nil)
24
24
  super
25
25
 
@@ -19,7 +19,7 @@ module Spree
19
19
  # @param amount [BigDecimal, Numeric, nil] defaults to what the return
20
20
  # is still owed
21
21
  # @param refund_method [String] 'original_payment' or 'store_credit'
22
- # @param refunder [Object, nil] the admin issuing it
22
+ # @param refunder [Object, nil] who is issuing it (see Spree.actor_classes)
23
23
  def perform(return_record:, amount: nil, refund_method: 'original_payment', refunder: nil)
24
24
  super
25
25
 
@@ -1102,6 +1102,7 @@ en:
1102
1102
  store_is_already_set: Store is already set
1103
1103
  unknown_custom_field_type: 'is not a known custom field type (expected one of: %{types})'
1104
1104
  unknown_scopes: 'contains unknown scopes: %{scopes}'
1105
+ unregistered_actor: is not a registered actor class
1105
1106
  variant_product_mismatch: must belong to the same product as the variant
1106
1107
  services:
1107
1108
  get_shipping_rates:
@@ -0,0 +1,30 @@
1
+ # Makes the order-operation "who did this" associations polymorphic, so a
2
+ # write made with a secret API key records the key rather than nobody.
3
+ # See docs/plans/6.0-action-actors.md.
4
+ class OrderOperationActorsPolymorphic < ActiveRecord::Migration[8.1]
5
+ ACTORS = {
6
+ spree_orders: %i[created_by approver canceler],
7
+ spree_refunds: %i[refunder],
8
+ spree_returns: %i[created_by],
9
+ spree_exchanges: %i[created_by],
10
+ spree_claims: %i[created_by],
11
+ spree_stock_receipts: %i[received_by]
12
+ }.freeze
13
+
14
+ def change
15
+ ACTORS.each do |table, names|
16
+ names.each do |name|
17
+ # Nullable with no default: the type is data, filled by
18
+ # `rake spree:upgrade:backfill_actor_types`. Until it runs, the
19
+ # transitional reader resolves these rows through the admin user
20
+ # class — the only thing they could ever have pointed at.
21
+ add_column table, :"#{name}_type", :string
22
+
23
+ # Named for the pair so it reads apart from the `_id` index that
24
+ # already exists on most of these columns.
25
+ add_index table, [:"#{name}_type", :"#{name}_id"],
26
+ name: "index_#{table}_on_#{name}_actor"
27
+ end
28
+ end
29
+ end
30
+ end
@@ -76,7 +76,7 @@ module Spree
76
76
  # the second run with "Spree::Brand is already used in your application".
77
77
  class_option :skip_collision_check, type: :boolean, default: true, desc: false
78
78
 
79
- desc 'Scaffold a complete v3-conformant API resource (model + migration + controllers + serializers + routes + factory + specs)'
79
+ desc 'Creates a new Spree API resource'
80
80
 
81
81
  # --- Owned-once gating ---
82
82
  #
@@ -171,8 +171,10 @@ module Spree
171
171
 
172
172
  routes_file = api_routes_path
173
173
 
174
+ # An installed gem is read-only, which is the normal case: a host app
175
+ # declares its own routes instead, through the engine's route hook.
174
176
  unless File.exist?(routes_file) && File.writable?(routes_file)
175
- say_status :skip, "routes.rb at #{routes_file} (not writable — only edge installs can modify gem source)", :yellow
177
+ draw_app_routes
176
178
  return
177
179
  end
178
180
 
@@ -180,11 +182,67 @@ module Spree
180
182
  inject_route_for(:admin, admin_route_line) if options[:admin]
181
183
  end
182
184
 
185
+ no_tasks do
186
+ # A host app declares its API routes in config/routes.rb, inside the
187
+ # engine's route hook that spree-starter ships ready to fill in. The
188
+ # block must be registered before the engine draws its routes, which is
189
+ # why it lives in routes.rb rather than an initializer.
190
+ def draw_app_routes
191
+ path = 'config/routes.rb'
192
+ full = File.join(destination_root, path)
193
+
194
+ unless File.exist?(full)
195
+ say_status :skip, "#{path} not found — add the routes by hand", :yellow
196
+ return
197
+ end
198
+
199
+ unless File.read(full).include?('Spree::Core::Engine.add_routes')
200
+ say_status :skip, "#{path} has no `Spree::Core::Engine.add_routes` block — add the routes by hand", :yellow
201
+ return
202
+ end
203
+
204
+ append_app_route(path, :store, store_route_line.strip) if options[:store]
205
+ append_app_route(path, :admin, admin_route_line.strip) if options[:admin]
206
+ end
207
+
208
+ # Inserts one `resources` line into the named namespace. Idempotent: a
209
+ # resource already declared there is left alone, so re-runs are safe.
210
+ def append_app_route(path, namespace, route_line)
211
+ full = File.join(destination_root, path)
212
+ content = File.read(full)
213
+
214
+ # Confined to the engine's route hook: an application may well have its
215
+ # own `namespace :admin` earlier in the file, and a route inserted there
216
+ # would never be drawn on the engine.
217
+ hook = content[/^(\s*)Spree::Core::Engine\.add_routes do\n.*?^\1end\n/m]
218
+ unless hook
219
+ say_status :skip, "#{path} (no `Spree::Core::Engine.add_routes` block — add `#{route_line}` by hand)", :yellow
220
+ return
221
+ end
222
+
223
+ match = hook.match(/^(\s*)namespace :#{namespace} do\n/)
224
+ unless match
225
+ say_status :skip, "#{path} (no :#{namespace} namespace — add `#{route_line}` by hand)", :yellow
226
+ return
227
+ end
228
+
229
+ block = hook[match.end(0)..].to_s[/\A.*?^#{match[1]}end$/m].to_s
230
+ if block.include?(route_line)
231
+ say_status :identical, "#{path} (#{namespace}: #{route_line})", :blue
232
+ return
233
+ end
234
+
235
+ updated_hook = hook.sub(match[0], "#{match[0]}#{match[1]} #{route_line}\n")
236
+ File.write(full, content.sub(hook, updated_hook))
237
+ say_status :route, "#{path} (#{namespace}: #{route_line})", :green
238
+ end
239
+ end
240
+
183
241
  def print_summary
184
242
  say ''
185
243
  say "✓ Generated Spree::#{bare_class_name} API resource", :green
186
244
  say ''
187
- say " Prefixed ID: #{id_prefix}_xxxxxxxxxx (edit `has_prefix_id` in the model to change)"
245
+ say " Prefixed ID: #{id_prefix}_xxxxxxxxxx"
188
246
  if store_external_name != bare_class_name
189
247
  say " Store API: /api/v3/store/#{store_external_plural} (aliased from #{bare_class_name})"
190
248
  elsif options[:store]
@@ -196,9 +254,9 @@ module Spree
196
254
  say ' 1. Review the generated model — add validations, scopes, callbacks'
197
255
  say ' 2. Apply the migration: pnpm exec spree migrate'
198
256
  say ' 3. Set up authorization (CanCanCan ability) for the resource'
199
- say ' 4. Decide whether this resource is store-scoped (add `has_many` on Store)'
257
+ say " 4. Add `has_many :#{plural_name}` to Spree::Store" if store_scoped?
200
258
  if options[:store] || options[:admin]
201
- say ' 5. Run the specs: pnpm exec spree exec bundle exec rspec spec/controllers/spree/api/v3/'
259
+ say " #{store_scoped? ? 5 : 4}. Run the specs: pnpm exec spree rspec spec/controllers/spree/api/v3/"
202
260
  end
203
261
  say ''
204
262
  end
@@ -3,6 +3,11 @@ module Spree
3
3
  module V3
4
4
  module Admin
5
5
  class <%= plural_name.camelize %>Controller < ResourceController
6
+ # Which API key scope authorizes this endpoint — `read_<%= plural_name %>`
7
+ # and `write_<%= plural_name %>`. Point it at an existing resource name
8
+ # instead if this endpoint belongs under one.
9
+ scoped_resource :<%= plural_name %>
10
+
6
11
  protected
7
12
 
8
13
  def model_class
@@ -2,6 +2,9 @@ require 'spec_helper'
2
2
 
3
3
  RSpec.describe Spree::Api::V3::Admin::<%= plural_name.camelize %>Controller, type: :controller do
4
4
  render_views
5
+ # The controller is mounted by the engine, so its paths resolve
6
+ # through the engine's router rather than the application's.
7
+ routes { Spree::Core::Engine.routes }
5
8
 
6
9
  include_context 'API v3 Admin authenticated'
7
10
 
@@ -2,8 +2,11 @@ require 'spec_helper'
2
2
 
3
3
  RSpec.describe Spree::Api::V3::Store::<%= plural_name.camelize %>Controller, type: :controller do
4
4
  render_views
5
+ # The controller is mounted by the engine, so its paths resolve
6
+ # through the engine's router rather than the application's.
7
+ routes { Spree::Core::Engine.routes }
5
8
 
6
- include_context 'API v3 Store'
9
+ include_context 'API v3 Store guest'
7
10
 
8
11
  let!(:<%= singular_name %>) { create(:<%= singular_name %>) }
9
12
 
@@ -25,7 +25,24 @@ module Spree
25
25
  default: false,
26
26
  desc: 'Include Spree::HasCustomFields and Spree::Metadata concerns'
27
27
 
28
- desc 'Creates a new Spree model with prefixed IDs and Spree.base_class parent'
28
+ # On by default so every new resource is observable from the moment it
29
+ # exists — subscribers and webhooks both hang off these events, and a
30
+ # model that publishes nothing is invisible to integrations.
31
+ class_option :lifecycle_events,
32
+ type: :boolean,
33
+ default: true,
34
+ desc: 'Publish created/updated/deleted events'
35
+
36
+ # Store scoping is the default because commerce records belong to a store:
37
+ # catalog, configuration and orders are all per-store. Opt out with
38
+ # --no-store-scoped only for genuinely global reference data (countries,
39
+ # states, roles), which is rare enough to be the explicit case.
40
+ class_option :store_scoped,
41
+ type: :boolean,
42
+ default: true,
43
+ desc: 'Scope the model to a store'
44
+
45
+ desc 'Creates a new Spree model'
29
46
 
30
47
  def create_module_file
31
48
  return
@@ -44,6 +61,28 @@ module Spree
44
61
  options[:custom_fields]
45
62
  end
46
63
 
64
+ def lifecycle_events?
65
+ options[:lifecycle_events]
66
+ end
67
+
68
+ # Whether the record belongs to a store, however that reference got
69
+ # there: the concern below, or a `store:references` the caller passed.
70
+ # Uniqueness and indexes key off this, so a value stays unique within a
71
+ # store rather than across the whole installation.
72
+ def store_scoped?
73
+ options[:store_scoped] || declares_store_reference?
74
+ end
75
+
76
+ # The concern is what adds `belongs_to :store`, so a model that already
77
+ # declares one must not include it as well.
78
+ def includes_store_concern?
79
+ options[:store_scoped] && !declares_store_reference?
80
+ end
81
+
82
+ def declares_store_reference?
83
+ attributes.any? { |attribute| attribute.name == 'store' }
84
+ end
85
+
47
86
  def class_path
48
87
  ['spree']
49
88
  end
@@ -1,6 +1,10 @@
1
1
  class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
2
  def change
3
3
  create_table :<%= table_name %><%= primary_key_type %> do |t|
4
+ <% if includes_store_concern? -%>
5
+ t.references :store, null: false, index: true, foreign_key: false
6
+
7
+ <% end -%>
4
8
  <% attributes.reject(&:reference?).reject(&:virtual?).each do |attribute| -%>
5
9
  <% if attribute.password_digest? -%>
6
10
  t.string :password_digest, null: false
@@ -27,9 +31,14 @@ class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Mi
27
31
  name: 'index_<%= table_name %>_on_<%= attribute.name %>'
28
32
  <% end -%>
29
33
  <% attributes.select(&:has_uniq_index?).reject(&:token?).each do |attribute| -%>
34
+ <% if store_scoped? -%>
35
+ add_index :<%= table_name %>, [:store_id, :<%= attribute.name %>], unique: true,
36
+ name: 'index_<%= table_name %>_on_store_id_and_<%= attribute.name %>'
37
+ <% else -%>
30
38
  add_index :<%= table_name %>, :<%= attribute.name %>, unique: true,
31
39
  name: 'index_<%= table_name %>_on_<%= attribute.name %>'
32
40
  <% end -%>
41
+ <% end -%>
33
42
  <% attributes.select { |a| a.has_index? && !a.has_uniq_index? && !a.reference? && !a.token? }.each do |attribute| -%>
34
43
  add_index :<%= table_name %>, :<%= attribute.name %>
35
44
  <% end -%>
@@ -1,5 +1,9 @@
1
1
  module Spree
2
2
  class <%= class_name.demodulize %> < <%= parent_class_name %>
3
+ <% if includes_store_concern? -%>
4
+ include Spree::SingleStoreResource
5
+
6
+ <% end -%>
3
7
  <% if custom_fields? -%>
4
8
  include Spree::HasCustomFields
5
9
  include Spree::Metadata
@@ -10,7 +14,9 @@ module Spree
10
14
 
11
15
  <% end -%>
12
16
  has_prefix_id :<%= id_prefix %>
13
-
17
+ <% if lifecycle_events? -%>
18
+ publishes_lifecycle_events
19
+ <% end -%>
14
20
  <% attributes.select(&:reference?).each do |attribute| -%>
15
21
  <% if attribute.polymorphic? -%>
16
22
  belongs_to :<%= attribute.name %>, polymorphic: true
@@ -38,10 +44,10 @@ module Spree
38
44
  has_secure_password
39
45
  <% end -%>
40
46
  <% required_attrs = attributes.reject { |a| a.reference? || a.type == :boolean || a.token? || a.attachment? || a.attachments? || a.rich_text? || a.password_digest? } -%>
41
- <% if required_attrs.any? %>
42
- <% required_attrs.each do |attr| -%>
43
- validates :<%= attr.name %>, presence: true<% if attr.has_uniq_index? %>, uniqueness: { scope: spree_base_uniqueness_scope }<% end %>
47
+ <% if required_attrs.any? -%>
44
48
 
49
+ <% required_attrs.each do |attr| -%>
50
+ validates :<%= attr.name %>, presence: true<% if attr.has_uniq_index? %>, uniqueness: { scope: [<%= ':store_id, ' if store_scoped? %>*spree_base_uniqueness_scope] }<% end %>
45
51
  <% end -%>
46
52
  <% end -%>
47
53
  <% ransackable = attributes.reject { |a| a.reference? || a.attachment? || a.attachments? || a.rich_text? || a.token? || a.password_digest? }.map(&:name) -%>