spree_api 6.0.0.rc5 → 6.0.0.rc7

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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/spree/api/v3/admin/auth_cookies.rb +23 -0
  3. data/app/controllers/concerns/spree/api/v3/admin/refund_authorization.rb +26 -0
  4. data/app/controllers/concerns/spree/api/v3/admin_authentication.rb +1 -0
  5. data/app/controllers/concerns/spree/api/v3/api_key_authentication.rb +14 -2
  6. data/app/controllers/concerns/spree/api/v3/cart_resolvable.rb +15 -2
  7. data/app/controllers/concerns/spree/api/v3/digital_assets_resource.rb +68 -0
  8. data/app/controllers/concerns/spree/api/v3/error_handler.rb +3 -0
  9. data/app/controllers/concerns/spree/api/v3/jwt_authentication.rb +27 -1
  10. data/app/controllers/concerns/spree/api/v3/orders/fulfillment_actions.rb +6 -1
  11. data/app/controllers/spree/api/v3/admin/allowed_origins_controller.rb +4 -1
  12. data/app/controllers/spree/api/v3/admin/auth_controller.rb +32 -5
  13. data/app/controllers/spree/api/v3/admin/commission_lines_controller.rb +2 -2
  14. data/app/controllers/spree/api/v3/admin/custom_fields_controller.rb +4 -7
  15. data/app/controllers/spree/api/v3/admin/customer_groups_controller.rb +4 -0
  16. data/app/controllers/spree/api/v3/admin/customers/store_credits_controller.rb +1 -1
  17. data/app/controllers/spree/api/v3/admin/customers_controller.rb +13 -8
  18. data/app/controllers/spree/api/v3/admin/delivery_profiles/origin_groups_controller.rb +1 -1
  19. data/app/controllers/spree/api/v3/admin/delivery_profiles_controller.rb +1 -1
  20. data/app/controllers/spree/api/v3/admin/digital_links_controller.rb +3 -1
  21. data/app/controllers/spree/api/v3/admin/invitation_acceptances_controller.rb +2 -0
  22. data/app/controllers/spree/api/v3/admin/me_controller.rb +2 -0
  23. data/app/controllers/spree/api/v3/admin/option_types_controller.rb +13 -0
  24. data/app/controllers/spree/api/v3/admin/orders/claims_controller.rb +2 -0
  25. data/app/controllers/spree/api/v3/admin/orders/exchanges_controller.rb +9 -0
  26. data/app/controllers/spree/api/v3/admin/orders/fulfillments_controller.rb +1 -1
  27. data/app/controllers/spree/api/v3/admin/orders/payments_controller.rb +7 -4
  28. data/app/controllers/spree/api/v3/admin/orders/returns_controller.rb +2 -0
  29. data/app/controllers/spree/api/v3/admin/orders_controller.rb +10 -2
  30. data/app/controllers/spree/api/v3/admin/password_resets_controller.rb +7 -1
  31. data/app/controllers/spree/api/v3/admin/price_lists_controller.rb +18 -0
  32. data/app/controllers/spree/api/v3/admin/product_types_controller.rb +12 -1
  33. data/app/controllers/spree/api/v3/admin/products/digital_assets_controller.rb +5 -109
  34. data/app/controllers/spree/api/v3/admin/products/variants_controller.rb +4 -1
  35. data/app/controllers/spree/api/v3/admin/products_controller.rb +28 -3
  36. data/app/controllers/spree/api/v3/admin/sellers/payouts_controller.rb +4 -3
  37. data/app/controllers/spree/api/v3/admin/stock_transfers_controller.rb +3 -2
  38. data/app/controllers/spree/api/v3/admin/store_controller.rb +7 -0
  39. data/app/controllers/spree/api/v3/admin/tags_controller.rb +11 -9
  40. data/app/controllers/spree/api/v3/admin/webhook_endpoints_controller.rb +5 -2
  41. data/app/controllers/spree/api/v3/resource_controller.rb +15 -2
  42. data/app/controllers/spree/api/v3/seller/auth_controller.rb +10 -0
  43. data/app/controllers/spree/api/v3/seller/invitation_acceptances_controller.rb +2 -0
  44. data/app/controllers/spree/api/v3/seller/me_controller.rb +4 -0
  45. data/app/controllers/spree/api/v3/seller/orders_controller.rb +11 -3
  46. data/app/controllers/spree/api/v3/seller/password_resets_controller.rb +5 -1
  47. data/app/controllers/spree/api/v3/seller/products/digital_assets_controller.rb +67 -0
  48. data/app/controllers/spree/api/v3/seller/products_controller.rb +4 -2
  49. data/app/controllers/spree/api/v3/seller/team_controller.rb +1 -1
  50. data/app/controllers/spree/api/v3/store/carts/store_credits_controller.rb +9 -1
  51. data/app/controllers/spree/api/v3/store/carts_controller.rb +14 -0
  52. data/app/controllers/spree/api/v3/store/customers_controller.rb +13 -1
  53. data/app/controllers/spree/api/v3/store/delivery_methods_controller.rb +6 -1
  54. data/app/controllers/spree/api/v3/store/products/filters_controller.rb +19 -4
  55. data/app/request_schemas/spree/api/v3/digital_asset_request_schema.rb +25 -0
  56. data/app/serializers/concerns/spree/api/v3/digital_asset_details.rb +70 -0
  57. data/app/serializers/spree/api/v3/admin/delivery_origin_group_serializer.rb +2 -2
  58. data/app/serializers/spree/api/v3/admin/delivery_profile_serializer.rb +3 -5
  59. data/app/serializers/spree/api/v3/admin/digital_asset_serializer.rb +3 -47
  60. data/app/serializers/spree/api/v3/admin/newsletter_subscriber_serializer.rb +1 -0
  61. data/app/serializers/spree/api/v3/admin/price_history_serializer.rb +1 -1
  62. data/app/serializers/spree/api/v3/admin/price_list_serializer.rb +8 -3
  63. data/app/serializers/spree/api/v3/admin/price_serializer.rb +1 -1
  64. data/app/serializers/spree/api/v3/admin/seller_payout_serializer.rb +2 -2
  65. data/app/serializers/spree/api/v3/admin/seller_serializer.rb +1 -3
  66. data/app/serializers/spree/api/v3/admin/store_credit_serializer.rb +3 -5
  67. data/app/serializers/spree/api/v3/base_serializer.rb +6 -1
  68. data/app/serializers/spree/api/v3/newsletter_subscriber_serializer.rb +5 -1
  69. data/app/serializers/spree/api/v3/seller/digital_asset_serializer.rb +16 -0
  70. data/app/serializers/spree/api/v3/seller/payout_serializer.rb +2 -2
  71. data/app/serializers/spree/api/v3/seller/store_serializer.rb +18 -0
  72. data/app/services/spree/api/v3/filters_aggregator.rb +63 -18
  73. data/config/locales/en.yml +1 -0
  74. data/config/routes.rb +3 -0
  75. data/lib/spree/api/dependencies.rb +2 -2
  76. data/lib/spree/api/engine.rb +6 -0
  77. data/lib/spree/api/middleware/no_index.rb +33 -0
  78. data/lib/spree/api/openapi/schema_helper.rb +2 -1
  79. data/lib/spree/api/testing_support/v3/base.rb +18 -0
  80. metadata +14 -6
@@ -10,6 +10,7 @@ module Spree
10
10
  # an amount.
11
11
  class ReturnsController < BaseController
12
12
  include Spree::Api::V3::Orders::ReturnActions
13
+ include Spree::Api::V3::Admin::RefundAuthorization
13
14
 
14
15
  # Returns are a subject of the `orders` catalog resource (see
15
16
  # Spree::PermissionConfiguration), so `read_orders`/`write_orders`
@@ -57,6 +58,7 @@ module Spree
57
58
  end
58
59
 
59
60
  before_action :set_resource, only: [:show, :update, :approve, :receive, :refund, :cancel]
61
+ before_action :require_refund_permission!, only: :refund
60
62
 
61
63
  # PATCH /api/v3/admin/orders/:order_id/returns/:id
62
64
  #
@@ -4,6 +4,7 @@ module Spree
4
4
  module Admin
5
5
  class OrdersController < ResourceController
6
6
  include Spree::Api::V3::OrderLock
7
+ include Spree::Api::V3::Admin::RefundAuthorization
7
8
 
8
9
  scoped_resource :orders
9
10
 
@@ -86,6 +87,7 @@ module Spree
86
87
 
87
88
  skip_before_action :set_resource, only: [:index, :create]
88
89
  before_action :set_resource, only: [:show, :update, :destroy, :complete, :cancel, :approve, :resend_confirmation, :resend_digital_links, :po_document]
90
+ before_action :require_refund_permission!, only: :cancel, if: -> { params[:refund_payments].to_b }
89
91
 
90
92
  # POST /api/v3/admin/orders
91
93
  def create
@@ -231,7 +233,9 @@ module Spree
231
233
  # `fields` list names an order's attributes, and narrowing a group by
232
234
  # it can drop the very keys that say it is one.
233
235
  def serialize_completion(record)
234
- return serialize_resource(record.reload) unless record.is_a?(Spree::OrderGroup)
236
+ # Read back lazily preloading, as #show is, so its fulfillments and
237
+ # their rows load once for all of them rather than per fulfillment.
238
+ return serialize_resource(Spree::Order.preload_associations_lazily.find(record.id)) unless record.is_a?(Spree::OrderGroup)
235
239
 
236
240
  Spree.api.admin_order_group_serializer.new(completed_group(record), params: serializer_params).to_h
237
241
  end
@@ -243,7 +247,7 @@ module Spree
243
247
  :customer, :ship_address, :bill_address,
244
248
  { payments: %i[payment_method source] },
245
249
  { orders: [:seller, :market, :gift_card, :customer, { line_items: { variant: :prices } }] }
246
- ).find(group.id)
250
+ ).preload_associations_lazily.find(group.id)
247
251
  end
248
252
 
249
253
  # Override scope — Order uses SingleStoreResource (for_store).
@@ -298,8 +302,12 @@ module Spree
298
302
  # are loaded with their selected rate because the freight summary
299
303
  # reads it — otherwise a page of orders costs a query per
300
304
  # fulfillment for a field that is nil on every parcel order.
305
+ # Both addresses are named because they are two associations on
306
+ # one table, so a row that reads them separately costs a lookup
307
+ # each.
301
308
  [:customer, :channel, :seller, :external_references, :cancel_reason,
302
309
  :market, { fulfillments: %i[selected_delivery_rate external_references] },
310
+ :bill_address, :ship_address,
303
311
  { line_items: [:external_references, { variant: :prices }, { price_list: :catalog }] },
304
312
  { po_document_attachment: :blob }]
305
313
  end
@@ -36,7 +36,9 @@ module Spree
36
36
  def create
37
37
  user = Spree.admin_user_class.find_by(email: params[:email])
38
38
 
39
- if user
39
+ # A deactivated account is treated as no account: no email is sent,
40
+ # and the response is the same 202.
41
+ if user && !account_deactivated?(user)
40
42
  event_name = 'admin_user.password_reset_requested'
41
43
  payload = user.event_payload_for(
42
44
  event_name,
@@ -62,6 +64,10 @@ module Spree
62
64
  )
63
65
  end
64
66
 
67
+ # A link sent before the account was switched off must not change
68
+ # its password or sign it in.
69
+ return render_account_deactivated if account_deactivated?(user)
70
+
65
71
  if user.update(password: params[:password], password_confirmation: params[:password_confirmation])
66
72
  user.publish_event('admin_user.password_reset')
67
73
  # A password reset must kill every existing session — a stolen
@@ -123,8 +123,26 @@ module Spree
123
123
  [:price_adjustment_tiers]
124
124
  end
125
125
 
126
+ def serializer_params
127
+ return super unless action_name == 'index'
128
+
129
+ super.merge(page_counts)
130
+ end
131
+
126
132
  private
127
133
 
134
+ # One grouped count per figure for the whole page rather than two
135
+ # queries per list.
136
+ def page_counts
137
+ @page_counts ||= begin
138
+ price_list_ids = collection.map(&:id)
139
+ {
140
+ products_counts: Spree::PriceList.products_count_by_list(price_list_ids),
141
+ prices_counts: Spree::PriceList.prices_count_by_list(price_list_ids)
142
+ }
143
+ end
144
+ end
145
+
128
146
  # Loads the record without the action-derived authorization
129
147
  # `set_resource` runs (which would check `:activate` /
130
148
  # `:deactivate` — actions that abilities don't grant). The
@@ -76,9 +76,21 @@ module Spree
76
76
  def permitted_params
77
77
  attrs = params.permit(*permitted_attributes)
78
78
  attrs = scope_delivery_profile(attrs)
79
+ attrs = scope_option_type_ids(attrs)
79
80
  scope_category_ids(attrs)
80
81
  end
81
82
 
83
+ # `option_type_ids=` resolves prefixed ids with no store scoping, so a
84
+ # type could otherwise seed another store's option types onto every
85
+ # product it is applied to.
86
+ def scope_option_type_ids(attrs)
87
+ return attrs if attrs[:option_type_ids].blank?
88
+
89
+ option_type_ids = decode_ids(attrs[:option_type_ids], Spree::OptionType)
90
+ attrs[:option_type_ids] = Spree::OptionType.for_store(current_store).where(id: option_type_ids).pluck(:id)
91
+ attrs
92
+ end
93
+
82
94
  # The template profile must belong to this store — a cross-store id
83
95
  # 404s instead of silently linking.
84
96
  def scope_delivery_profile(attrs)
@@ -93,7 +105,6 @@ module Spree
93
105
  # `category_ids=` resolves prefixed ids with no store scoping, so a
94
106
  # type in this store could otherwise be pointed at another store's
95
107
  # categories. Mirrors ProductsController#apply_categories.
96
- # (OptionType is global — nothing to scope there.)
97
108
  def scope_category_ids(attrs)
98
109
  return attrs if attrs[:category_ids].blank?
99
110
 
@@ -4,61 +4,19 @@ module Spree
4
4
  module Admin
5
5
  module Products
6
6
  class DigitalAssetsController < ResourceController
7
+ include Spree::Api::V3::DigitalAssetsResource
8
+
7
9
  scoped_resource :products
8
10
 
9
11
  request_params :digital_asset do |body|
10
- # Names a blob rather than a column, so it is attached
11
- # separately (+attach_uploaded_file+).
12
- body.attribute :signed_id, :string,
13
- permit: false,
14
- description: "A `signed_id` from `files.upload()` with `visibility: 'private'`. Required for an uploaded file, omitted for a provider-backed asset. On update it replaces the file for everyone who already bought it."
15
- # Resolved through the parent rather than assigned, so an asset
16
- # can't be pointed at a variant of another product or store.
17
- body.attribute :variant_id, :id,
18
- permit: false,
19
- description: "Defaults to the product's default variant when omitted."
12
+ body.include_attributes Spree::Api::V3::DigitalAssetRequestSchema
20
13
  # The model validates it names a registered provider.
21
14
  body.attribute :provider_type, :string,
22
- description: 'A provider shorthand from the providers endpoint. Omit for an uploaded file.'
15
+ description: 'A provider shorthand from the providers endpoint. Omit for an uploaded file; a provider-backed asset sends no `signed_id`.'
23
16
  # Its keys are declared by the provider, so it lands verbatim
24
17
  # under one key in the asset's metadata.
25
18
  body.attribute :provider_settings, :hash,
26
19
  description: "Values for the provider's settings schema, keyed by field name."
27
- body.attribute :authorized_clicks, :integer,
28
- nullable: true,
29
- description: "Null means the store's download settings apply."
30
- body.attribute :authorized_days, :integer,
31
- nullable: true,
32
- description: "Null means the store's download settings apply."
33
- end
34
-
35
- # PATCH /api/v3/admin/products/:product_id/digital_assets/:id
36
- #
37
- # Replacing the file keeps every already-issued link working, since
38
- # links resolve through the asset rather than the blob.
39
- def update
40
- return render_private_storage_error unless uploaded_blob_private?
41
-
42
- # The asset already exists, so attaching a replacement blob writes
43
- # to the database immediately, while the update below renders a 422
44
- # without raising when it fails. Wrap both so a failed update rolls
45
- # the new attachment back rather than leaving the file swapped but
46
- # the record unchanged.
47
- model_class.transaction do
48
- @resource.variant = requested_variant if params[:variant_id].present?
49
- attach_uploaded_file(@resource)
50
-
51
- super
52
-
53
- raise ActiveRecord::Rollback if @resource.errors.any?
54
- end
55
- end
56
-
57
- # POST /api/v3/admin/products/:product_id/digital_assets
58
- def create
59
- return render_private_storage_error unless uploaded_blob_private?
60
-
61
- super
62
20
  end
63
21
 
64
22
  # GET /api/v3/admin/products/:product_id/digital_assets/providers
@@ -83,75 +41,13 @@ module Spree
83
41
 
84
42
  protected
85
43
 
86
- def model_class
87
- Spree::DigitalAsset
88
- end
89
-
90
44
  def serializer_class
91
45
  Spree.api.admin_digital_asset_serializer
92
46
  end
93
47
 
94
48
  def set_parent
95
49
  @parent = current_store.products.find_by_prefix_id!(params[:product_id])
96
- authorize!(:show, @parent)
97
- end
98
-
99
- # The parent association reaches assets through `variants`, which
100
- # carries an ORDER BY on the variants table. Combined with the
101
- # collection's DISTINCT, PostgreSQL rejects the query outright
102
- # (ordering columns must appear in the select list), so order by
103
- # the assets' own table instead. SQLite accepts either form, so
104
- # this only fails on a real deployment.
105
- def scope
106
- super.reorder(created_at: :asc)
107
- end
108
-
109
- def scope_includes
110
- [:variant, { attachment_attachment: :blob }]
111
- end
112
-
113
- def permitted_params
114
- params.permit(*declared_permitted_attributes)
115
- end
116
-
117
- private
118
-
119
- # A product without explicit variants keeps its file on the default
120
- # variant, mirroring how the rest of the admin treats such products.
121
- def build_resource
122
- digital_asset = super
123
- digital_asset.variant = requested_variant || @parent.default_variant
124
- attach_uploaded_file(digital_asset)
125
- digital_asset
126
- end
127
-
128
- # Scoped to the parent, not merely to the store: an asset reached
129
- # through a product's route has no business pointing at another
130
- # product's variant. An id from anywhere else is a 404.
131
- def requested_variant
132
- return if params[:variant_id].blank?
133
-
134
- @parent.variants.find_by_prefix_id!(params[:variant_id])
135
- end
136
-
137
- def attach_uploaded_file(digital_asset)
138
- return if params[:signed_id].blank?
139
-
140
- digital_asset.attachment.attach(params[:signed_id])
141
- end
142
-
143
- # Attaching never moves a blob between storage services, so a file
144
- # uploaded to the public bucket would stay publicly readable while
145
- # looking attached. Refuse it rather than storing it wrongly.
146
- def uploaded_blob_private?
147
- return true if params[:signed_id].blank?
148
-
149
- blob = ActiveStorage::Blob.find_signed(params[:signed_id])
150
- blob.present? && blob.service_name.to_s == Spree.private_storage_service_name.to_s
151
- end
152
-
153
- def render_private_storage_error
154
- render_validation_error(I18n.t('spree.digital_assets_attachment_must_be_private'))
50
+ authorize_parent!(@parent)
155
51
  end
156
52
  end
157
53
  end
@@ -28,8 +28,11 @@ module Spree
28
28
  authorize!(:show, @parent)
29
29
  end
30
30
 
31
+ # `active_stock_reservations` is what lets the quantifier count
32
+ # held stock in memory; without it the serializer's availability
33
+ # column sums reservations once per variant.
31
34
  def scope_includes
32
- [:prices, stock_levels: :stock_location]
35
+ [:prices, { stock_levels: [:stock_location, :active_stock_reservations] }]
33
36
  end
34
37
 
35
38
  def create_workflow
@@ -219,7 +219,7 @@ module Spree
219
219
 
220
220
  channels = scoped_channels
221
221
  product_ids = bulk_collection.distinct.ids
222
- channels.find_each { |channel| channel.add_products(product_ids) }
222
+ channels.add_products(product_ids)
223
223
 
224
224
  render json: { product_count: product_ids.size, channel_count: channels.size }
225
225
  end
@@ -231,7 +231,7 @@ module Spree
231
231
 
232
232
  channels = scoped_channels
233
233
  product_ids = bulk_collection.distinct.ids
234
- removed = channels.sum { |channel| channel.remove_products(product_ids) }
234
+ removed = channels.remove_products(product_ids)
235
235
 
236
236
  render json: { product_count: product_ids.size, channel_count: channels.size, removed: removed }
237
237
  end
@@ -293,6 +293,14 @@ module Spree
293
293
  { media: Spree::Media, variants: Spree::Variant }
294
294
  end
295
295
 
296
+ # A written product is serialized from a fresh read: lazy preloading
297
+ # does not reach records a write built, so the instance the workflow
298
+ # returned would query once per variant.
299
+ def serialize_resource(resource)
300
+ resource = scope.where(id: resource.id).preload_associations_lazily.take! if resource.lazy_preload_context.nil?
301
+ super
302
+ end
303
+
296
304
  # Use SearchProvider::Database for collection to handle price/best_selling
297
305
  # sorting correctly (counts before sorting, avoiding PG/Mobility issues).
298
306
  def collection
@@ -323,7 +331,24 @@ module Spree
323
331
  end
324
332
 
325
333
  def permitted_params
326
- params.permit(*permitted_attributes).tap { |attrs| attrs.delete(:status) if review_status?(attrs[:status]) }
334
+ params.permit(*permitted_attributes).tap do |attrs|
335
+ attrs.delete(:status) if review_status?(attrs[:status])
336
+ scope_store_references(attrs)
337
+ end
338
+ end
339
+
340
+ # Read through the store here because the model only checks that the
341
+ # record exists: another store's type or tax category would otherwise
342
+ # be accepted, and the type would seed its option types onto the
343
+ # product.
344
+ STORE_REFERENCES = { product_type_id: :product_types, tax_category_id: :tax_categories }.freeze
345
+
346
+ def scope_store_references(attrs)
347
+ STORE_REFERENCES.each do |key, association|
348
+ next if attrs[key].blank?
349
+
350
+ attrs[key] = current_store.public_send(association).find_by_param!(attrs[key]).id
351
+ end
327
352
  end
328
353
 
329
354
  # A review status is an outcome, not a value to assign: `proposed`
@@ -30,7 +30,8 @@ module Spree
30
30
  # because another went through: the payout row exists and is
31
31
  # failed or unresolved, and an operator reading "settled" would
32
32
  # not know to go looking for it.
33
- body = { data: settled.map { |payout| serialize_payout(payout) } }
33
+ transfer_counts = Spree::SellerTransfer.where(payout_id: settled.map(&:id)).group(:payout_id).count
34
+ body = { data: settled.map { |payout| serialize_payout(payout, transfer_counts) } }
34
35
  body[:meta] = { failures: failures } if failures.any?
35
36
 
36
37
  render json: body, status: :created
@@ -96,8 +97,8 @@ module Spree
96
97
  @seller.payable_currencies
97
98
  end
98
99
 
99
- def serialize_payout(payout)
100
- Spree.api.admin_seller_payout_serializer.new(payout, params: { store: current_store }).to_h
100
+ def serialize_payout(payout, transfer_counts)
101
+ Spree.api.admin_seller_payout_serializer.new(payout, params: { store: current_store, transfer_counts: transfer_counts }).to_h
101
102
  end
102
103
  end
103
104
  end
@@ -202,9 +202,10 @@ module Spree
202
202
  end
203
203
 
204
204
  # The listing and the detail page both read a transfer's totals off
205
- # its lines.
205
+ # its lines. Both warehouses come from one table, so their external
206
+ # references are loaded here rather than once per warehouse.
206
207
  def scope_includes
207
- [line_includes]
208
+ [{ source_location: :external_references, destination_location: :external_references }, line_includes]
208
209
  end
209
210
  end
210
211
  end
@@ -65,6 +65,13 @@ module Spree
65
65
  description: 'Admits a seller as soon as they finish onboarding, with no operator review.'
66
66
  body.attribute :auto_approve_seller_products, :boolean,
67
67
  description: 'Puts a seller product on sale the moment it is submitted, with no review.'
68
+ body.attribute :seller_digital_assets_enabled, :boolean,
69
+ description: 'Lets sellers attach downloadable files to their own products.'
70
+ body.attribute :seller_digital_asset_content_types, :array, of: :string,
71
+ example: %w[application/pdf application/zip],
72
+ description: "The content types a seller's file may have, checked against the file's own signature where it has one. An entry names one type or, with `*` as the subtype, a whole family. The operator's own uploads are not restricted."
73
+ body.attribute :review_seller_digital_asset_changes, :boolean,
74
+ description: 'Sends a seller product already on sale back to review when the seller adds or replaces a file on it.'
68
75
  body.attribute :send_seller_transactional_emails, :boolean,
69
76
  description: 'When false, transactional seller emails (approval, payouts and so on) are not sent.'
70
77
  body.attribute :default_commission_tax_rate, :decimal,
@@ -63,22 +63,24 @@ module Spree
63
63
  false
64
64
  end
65
65
 
66
- # Tagging filter. Store-owned taggables (`Spree::Product`,
67
- # `Spree::Order`) tenant their taggings by `store_id` via
68
- # `acts_as_taggable_tenant`, so their vocabulary is bounded to the
69
- # current store with a single indexed `tenant` filter. Global
70
- # taggables like the customer/user type carry no store, so their tags
71
- # are not store-scoped here.
66
+ # Tagging filter. A taggable declaring `acts_as_taggable_tenant :store_id`
67
+ # stores the owning store on each tagging, so its vocabulary is bounded
68
+ # to the current store with a single indexed `tenant` filter. Taggables
69
+ # without a store tenant are not store-scoped here.
72
70
  def taggings_conditions
73
71
  conditions = { taggable_type: taggable_type, context: 'tags' }
74
72
  conditions[:tenant] = current_store.id.to_s if store_tenanted_taggable?
75
73
  conditions
76
74
  end
77
75
 
78
- # True for taggables whose taggings carry a store `tenant`
79
- # (`acts_as_taggable_tenant :store_id`), i.e. store-owned records.
80
76
  def store_tenanted_taggable?
81
- %w[Spree::Product Spree::Order].include?(taggable_type)
77
+ taggable_class.try(:tenant_column).to_s == 'store_id'
78
+ end
79
+
80
+ # `taggable_type` is already matched against `Spree.taggable_types`,
81
+ # so only a registered class name is ever constantized.
82
+ def taggable_class
83
+ taggable_type.constantize
82
84
  end
83
85
 
84
86
  # Per-type scope check for API-key principals: a key listing product
@@ -21,7 +21,7 @@ module Spree
21
21
  description: 'Defaults to true on create. Prefer the disable and enable endpoints to pause one.'
22
22
  body.attribute :subscriptions, :array, of: :string,
23
23
  example: %w[order.placed order.canceled],
24
- description: 'Event names this endpoint receives. An empty list or `["*"]` subscribes to every event; otherwise list exact names (`order.completed`) or patterns (`order.*`).'
24
+ description: 'Event names this endpoint receives. An empty list or `["*"]` subscribes to every event; otherwise list exact names (`order.completed`) or patterns with `*` in place of a segment (`order.*`, `*.created`). At most 200 entries of up to 100 characters.'
25
25
  end
26
26
 
27
27
  request_params 'WebhookEndpointDisableParams', action: :disable do |body|
@@ -112,8 +112,11 @@ module Spree
112
112
  )
113
113
  end
114
114
 
115
+ # Checked after the model's normalization, so what the gate sees is
116
+ # exactly what would be stored.
115
117
  def requested_credential_events
116
- Spree::Events.catalog.credential_events(Array(permitted_params[:subscriptions]))
118
+ subscriptions = Spree::WebhookEndpoint.normalize_value_for(:subscriptions, permitted_params[:subscriptions])
119
+ Spree::Events.catalog.credential_events(subscriptions)
117
120
  end
118
121
 
119
122
  def repointed_credential_events
@@ -248,8 +248,11 @@ module Spree
248
248
  end
249
249
 
250
250
  # Finds a single resource within scope using prefixed ID
251
+ # Loaded through the lazy-preload relation, as the collection is, so
252
+ # the serializer's walk over the record's children batches their
253
+ # associations instead of querying once per child.
251
254
  def find_resource
252
- scope.find_by_prefix_id!(params[:id])
255
+ scope.preload_associations_lazily.find_by_prefix_id!(params[:id])
253
256
  end
254
257
 
255
258
  # Per-record authorization hook. A no-op in the shared base: the Store
@@ -481,13 +484,23 @@ module Spree
481
484
  base_scope = if @parent.present?
482
485
  @parent.send(parent_association)
483
486
  else
484
- model_class.for_store(current_store)
487
+ store_scope
485
488
  end
486
489
  base_scope = base_scope.includes(scope_includes) if scope_includes.any?
487
490
  base_scope = base_scope.preload_associations_lazily
488
491
  model_class.include?(Spree::TranslatableResource) ? base_scope.i18n : base_scope
489
492
  end
490
493
 
494
+ # The store's rows of this model when the resource has no parent. A
495
+ # model the store reaches through another record rather than an
496
+ # association of its own — a commission line through its order —
497
+ # names that path here, since `for_store` answers nothing for it.
498
+ #
499
+ # @return [ActiveRecord::Relation]
500
+ def store_scope
501
+ model_class.for_store(current_store)
502
+ end
503
+
491
504
  # Action names treated as reads. Override in subclasses with custom
492
505
  # read-only member/collection actions (e.g. add `analytics`, `types`)
493
506
  # so they map to the `:show` ability instead of a write.
@@ -52,6 +52,8 @@ module Spree
52
52
  return render_authentication_failed(result.error) unless result.success?
53
53
 
54
54
  user = result.value
55
+ return render_account_deactivated if account_deactivated?(user)
56
+
55
57
  # A store's own staff share this user class, so authenticating is
56
58
  # not enough: without a role on some seller there is no panel to
57
59
  # sign in to, and minting a token would hand out an audience the
@@ -101,6 +103,14 @@ module Spree
101
103
  end
102
104
 
103
105
  user = refresh_token.user
106
+ # Deactivating deletes the account's refresh tokens; this catches a
107
+ # refresh already under way when that happened.
108
+ if account_deactivated?(user)
109
+ refresh_token.destroy
110
+ clear_refresh_cookie
111
+ return render_account_deactivated
112
+ end
113
+
104
114
  # Membership can be revoked while a session is live; the refresh is
105
115
  # where that takes effect.
106
116
  unless user.seller_member?
@@ -46,6 +46,8 @@ module Spree
46
46
 
47
47
  user = resolve_or_create_invitee(@invitation)
48
48
  return if performed?
49
+ # Checked before accepting: a deactivated account gains no role.
50
+ return render_account_deactivated if account_deactivated?(user)
49
51
 
50
52
  @invitation.invitee = user
51
53
  result = Spree.invitation_accept_workflow.call(invitation: @invitation)
@@ -58,6 +58,10 @@ module Spree
58
58
  {
59
59
  user: Spree.api.seller_account_serializer.new(current_user, params: serializer_params).to_h,
60
60
  sellers: serialized_sellers,
61
+ # The marketplace's own settings that shape what the panel lets a
62
+ # seller do, read before any seller is chosen since there is one
63
+ # store per installation.
64
+ store: Spree.api.seller_store_serializer.new(serializer_params[:store], params: serializer_params).to_h,
61
65
  # Both shapes, exactly as the admin `/me`: the panel's `<Can>`
62
66
  # reads CanCanCan rules, the key gate reads keys. Sending only
63
67
  # keys left `<Can>` answering false for everything on the seller
@@ -84,7 +84,7 @@ module Spree
84
84
  )
85
85
 
86
86
  if result.success?
87
- render json: serialize_resource(@resource.reload)
87
+ render json: serialize_resource(reloaded_resource)
88
88
  else
89
89
  render_service_error(@resource.errors.presence || result.error)
90
90
  end
@@ -115,7 +115,7 @@ module Spree
115
115
  next render_validation_error(invalid.errors) if invalid
116
116
 
117
117
  if @resource.update(addresses)
118
- render json: serialize_resource(@resource.reload)
118
+ render json: serialize_resource(reloaded_resource)
119
119
  else
120
120
  render_validation_error(@resource.errors)
121
121
  end
@@ -143,12 +143,20 @@ module Spree
143
143
  super.not_drafts
144
144
  end
145
145
 
146
+ # Both addresses are named because they are two associations on one
147
+ # table, so a row that reads them separately costs a lookup each.
146
148
  def collection_includes
147
- [:line_items, :customer, :channel]
149
+ [:line_items, :customer, :channel, :bill_address, :ship_address]
148
150
  end
149
151
 
150
152
  private
151
153
 
154
+ # Read back as #show reads it, lazily preloading, so its
155
+ # fulfillments' rates and methods load once rather than per row.
156
+ def reloaded_resource
157
+ scope.preload_associations_lazily.find(@resource.id)
158
+ end
159
+
152
160
  # Either address, as a correction rather than a replacement: the
153
161
  # sent fields are laid over what the order already holds, so a
154
162
  # request naming one line does not blank out the country and
@@ -43,7 +43,8 @@ module Spree
43
43
  # admin who runs no seller has no panel to be sent to, and mailing
44
44
  # them a seller reset link would be an invitation to a session they
45
45
  # cannot hold.
46
- if user&.seller_member?
46
+ # A deactivated account is treated as no account: no email is sent.
47
+ if user&.seller_member? && !account_deactivated?(user)
47
48
  user.publish_event(RESET_REQUESTED_EVENT, event_payload(user))
48
49
  end
49
50
 
@@ -60,6 +61,9 @@ module Spree
60
61
  # it can be revoked between the email being sent and the link being
61
62
  # opened, and signing in is what the success path does.
62
63
  return render_token_invalid unless user&.seller_member?
64
+ # A link sent before the account was switched off must not change
65
+ # its password or sign it in.
66
+ return render_account_deactivated if account_deactivated?(user)
63
67
 
64
68
  unless user.update(password: params[:password], password_confirmation: params[:password_confirmation])
65
69
  return render_errors(user.errors)