spree_api 6.0.0.rc4 → 6.0.0.rc5

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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/spree/api/v3/allowed_redirect_url.rb +27 -0
  3. data/app/controllers/concerns/spree/api/v3/cart_resolvable.rb +8 -2
  4. data/app/controllers/concerns/spree/api/v3/scoped_authorization.rb +6 -2
  5. data/app/controllers/spree/api/v3/admin/companies/addresses_controller.rb +7 -1
  6. data/app/controllers/spree/api/v3/admin/custom_fields_controller.rb +30 -8
  7. data/app/controllers/spree/api/v3/admin/customers/addresses_controller.rb +4 -0
  8. data/app/controllers/spree/api/v3/admin/customers_controller.rb +1 -6
  9. data/app/controllers/spree/api/v3/admin/gift_cards_controller.rb +1 -8
  10. data/app/controllers/spree/api/v3/admin/orders_controller.rb +11 -5
  11. data/app/controllers/spree/api/v3/admin/package_types_controller.rb +2 -3
  12. data/app/controllers/spree/api/v3/admin/products/variants_controller.rb +3 -4
  13. data/app/controllers/spree/api/v3/admin/products_controller.rb +5 -3
  14. data/app/controllers/spree/api/v3/admin/store_controller.rb +1 -1
  15. data/app/controllers/spree/api/v3/admin/variants_controller.rb +96 -1
  16. data/app/controllers/spree/api/v3/oauth/authorizations_controller.rb +17 -6
  17. data/app/controllers/spree/api/v3/resource_controller.rb +35 -5
  18. data/app/controllers/spree/api/v3/seller/package_types_controller.rb +2 -2
  19. data/app/controllers/spree/api/v3/seller/products_controller.rb +7 -61
  20. data/app/controllers/spree/api/v3/seller/resource_controller.rb +2 -6
  21. data/app/controllers/spree/api/v3/store/carts/addresses_controller.rb +53 -0
  22. data/app/controllers/spree/api/v3/store/carts_controller.rb +3 -3
  23. data/app/controllers/spree/api/v3/store/companies/addresses_controller.rb +4 -0
  24. data/app/controllers/spree/api/v3/store/customer/addresses_controller.rb +4 -0
  25. data/app/controllers/spree/api/v3/store/customer/data_requests_controller.rb +19 -1
  26. data/app/controllers/spree/api/v3/store/customer/password_resets_controller.rb +3 -14
  27. data/app/controllers/spree/api/v3/store/data_request_confirmations_controller.rb +23 -0
  28. data/app/controllers/spree/api/v3/store/data_requests_controller.rb +56 -0
  29. data/app/controllers/spree/api/v3/store/newsletter_subscribers_controller.rb +2 -11
  30. data/app/request_schemas/spree/api/v3/admin/variant_request_schema.rb +21 -0
  31. data/app/request_schemas/spree/api/v3/seller/price_request_schema.rb +20 -0
  32. data/app/request_schemas/spree/api/v3/seller/variant_request_schema.rb +19 -0
  33. data/app/request_schemas/spree/api/v3/variant_request_schema.rb +8 -13
  34. data/app/serializers/spree/api/v3/address_owner_serializer.rb +27 -0
  35. data/app/serializers/spree/api/v3/address_serializer.rb +5 -1
  36. data/app/serializers/spree/api/v3/admin/address_owner_serializer.rb +10 -0
  37. data/app/serializers/spree/api/v3/admin/address_serializer.rb +1 -10
  38. data/app/serializers/spree/api/v3/admin/api_key_serializer.rb +1 -1
  39. data/app/serializers/spree/api/v3/admin/catalog_assignment_serializer.rb +1 -1
  40. data/app/serializers/spree/api/v3/admin/catalog_price_serializer.rb +1 -1
  41. data/app/serializers/spree/api/v3/admin/collection_rule_serializer.rb +1 -1
  42. data/app/serializers/spree/api/v3/admin/collection_serializer.rb +1 -1
  43. data/app/serializers/spree/api/v3/admin/commission_line_serializer.rb +2 -2
  44. data/app/serializers/spree/api/v3/admin/commission_rate_serializer.rb +1 -1
  45. data/app/serializers/spree/api/v3/admin/consent_record_serializer.rb +1 -1
  46. data/app/serializers/spree/api/v3/admin/email_template_serializer.rb +1 -1
  47. data/app/serializers/spree/api/v3/admin/order_serializer.rb +1 -1
  48. data/app/serializers/spree/api/v3/admin/package_type_serializer.rb +3 -3
  49. data/app/serializers/spree/api/v3/admin/permission_serializer.rb +1 -1
  50. data/app/serializers/spree/api/v3/admin/price_list_serializer.rb +1 -1
  51. data/app/serializers/spree/api/v3/admin/purchase_order_serializer.rb +1 -1
  52. data/app/serializers/spree/api/v3/admin/seller_requirement_status_serializer.rb +1 -1
  53. data/app/serializers/spree/api/v3/admin/seller_serializer.rb +2 -2
  54. data/app/serializers/spree/api/v3/admin/seller_transfer_serializer.rb +1 -1
  55. data/app/serializers/spree/api/v3/admin/shipping_label_serializer.rb +3 -3
  56. data/app/serializers/spree/api/v3/admin/stock_location_serializer.rb +1 -1
  57. data/app/serializers/spree/api/v3/admin/stock_receipt_item_serializer.rb +2 -2
  58. data/app/serializers/spree/api/v3/admin/stock_receipt_serializer.rb +1 -1
  59. data/app/serializers/spree/api/v3/admin/stock_transfer_serializer.rb +1 -1
  60. data/app/serializers/spree/api/v3/admin/store_serializer.rb +1 -0
  61. data/app/serializers/spree/api/v3/admin/tax_identifier_serializer.rb +2 -2
  62. data/app/serializers/spree/api/v3/admin/tax_line_serializer.rb +1 -1
  63. data/app/serializers/spree/api/v3/admin/variant_serializer.rb +1 -1
  64. data/app/serializers/spree/api/v3/base_serializer.rb +27 -5
  65. data/app/serializers/spree/api/v3/claim_serializer.rb +1 -1
  66. data/app/serializers/spree/api/v3/collection_serializer.rb +1 -1
  67. data/app/serializers/spree/api/v3/company_invitation_serializer.rb +1 -1
  68. data/app/serializers/spree/api/v3/company_serializer.rb +1 -1
  69. data/app/serializers/spree/api/v3/data_request_confirmation_requested_event_serializer.rb +28 -0
  70. data/app/serializers/spree/api/v3/data_request_event_serializer.rb +1 -1
  71. data/app/serializers/spree/api/v3/data_request_serializer.rb +8 -4
  72. data/app/serializers/spree/api/v3/delivery_serializer.rb +1 -1
  73. data/app/serializers/spree/api/v3/delivery_zone_member_serializer.rb +1 -1
  74. data/app/serializers/spree/api/v3/discount_serializer.rb +1 -1
  75. data/app/serializers/spree/api/v3/file_upload_serializer.rb +1 -1
  76. data/app/serializers/spree/api/v3/fulfillment_serializer.rb +1 -1
  77. data/app/serializers/spree/api/v3/media_event_serializer.rb +1 -1
  78. data/app/serializers/spree/api/v3/media_serializer.rb +1 -1
  79. data/app/serializers/spree/api/v3/option_type_serializer.rb +1 -1
  80. data/app/serializers/spree/api/v3/order_group_serializer.rb +2 -2
  81. data/app/serializers/spree/api/v3/order_serializer.rb +4 -2
  82. data/app/serializers/spree/api/v3/payment_serializer.rb +1 -1
  83. data/app/serializers/spree/api/v3/product_filter_availability_option_serializer.rb +1 -1
  84. data/app/serializers/spree/api/v3/product_filter_option_serializer.rb +1 -1
  85. data/app/serializers/spree/api/v3/seller/address_serializer.rb +3 -2
  86. data/app/serializers/spree/api/v3/seller/order_serializer.rb +3 -3
  87. data/app/serializers/spree/api/v3/seller/package_type_serializer.rb +3 -3
  88. data/app/serializers/spree/api/v3/seller/profile_serializer.rb +2 -2
  89. data/app/serializers/spree/api/v3/seller/requirement_status_serializer.rb +1 -1
  90. data/app/serializers/spree/api/v3/seller/tax_identifier_serializer.rb +1 -1
  91. data/app/serializers/spree/api/v3/seller/variant_serializer.rb +1 -1
  92. data/app/serializers/spree/api/v3/shipping_label_event_serializer.rb +1 -1
  93. data/app/serializers/spree/api/v3/stock_movement_serializer.rb +1 -1
  94. data/app/serializers/spree/api/v3/stock_receipt_event_serializer.rb +1 -1
  95. data/app/serializers/spree/api/v3/variant_serializer.rb +2 -2
  96. data/app/services/spree/api/v3/filter_table.rb +22 -3
  97. data/app/services/spree/api/v3/request_schema.rb +64 -17
  98. data/config/locales/en.yml +1 -0
  99. data/config/routes.rb +19 -11
  100. data/lib/spree/api/agent_write_schemas.rb +15 -3
  101. data/lib/spree/api/dependencies.rb +2 -2
  102. data/lib/spree/api/enum_types.rb +118 -0
  103. data/lib/spree/api/openapi/filter_documentation.rb +8 -1
  104. data/lib/spree/api/typelizer_generation.rb +5 -0
  105. data/lib/typelizer/open_enums.rb +40 -16
  106. metadata +17 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 50559a5f0c0305cee4fbf43e8d145078d0f976ffeed8a40be46f19e4d1cca64d
4
- data.tar.gz: '00688dd0b79f087aeee42271fd8678eacbaca3626115770fa29043cbbe3e3182'
3
+ metadata.gz: cf869734311256969147c858239c279f8aec3faac383f6bf030937eedc9fc54e
4
+ data.tar.gz: e8a7fa2d529fd09e06f6d9b3ad7007e5122c2b56ad0a0e3b32ce8fccb0d4d073
5
5
  SHA512:
6
- metadata.gz: d56fd3fd92f8bdd5257da98f842c366aa4ad324fcb5ef64c8cf44f007011144813da32af273828604db8cc2ce237fc17df51d633de6132594b41ab3002923c4d
7
- data.tar.gz: 34e4d178767f8feccad3b96dbffb9ec02727bce30cdf106a19b9fca97c3af8f44272cf06b4a5facef4864ff3216860538c1a742cc2a1acc26a1589ea1c12b3d9
6
+ metadata.gz: ef0d1a79bbdcd73ce34176fbe9054090214f326458c2840ac0937582334f639a4907df1cfa7bae30b566976bec1770fa32f455369eded75a8fc456b01efcbb08
7
+ data.tar.gz: 439f5b98f55aab51ce2d07f037336b175dce46c5e3bc9383685beebe35c34a7d519c004cf2697f68821b52ce538023a3de0dd68ee3b2522062a119db8906b59c
@@ -0,0 +1,27 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ # Reads the `redirect_url` a storefront passes for an emailed link.
5
+ #
6
+ # The link carries a live token, so a page outside the store's allowed
7
+ # origins would hand that token to whoever named the page. An address
8
+ # that does not match is dropped rather than refused, so callers cannot
9
+ # probe the allow-list through error responses.
10
+ module AllowedRedirectUrl
11
+ extend ActiveSupport::Concern
12
+
13
+ private
14
+
15
+ # @return [String, nil] the redirect URL when it matches one of the
16
+ # store's allowed origins
17
+ def validated_redirect_url
18
+ redirect_url = params[:redirect_url]
19
+ return if redirect_url.blank?
20
+ return unless current_store.allowed_origin?(redirect_url)
21
+
22
+ redirect_url
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -11,10 +11,16 @@ module Spree
11
11
  # Find cart by prefixed ID and authorize read access (owner or guest
12
12
  # token). Completed carts are not carts anymore — they 404 unless the
13
13
  # caller opts in (idempotent completion, payment-session confirm races).
14
+ #
15
+ # Pass +token: nil+ to demand ownership. A guest token proves the
16
+ # caller holds this cart, which is the right bar for the cart's own
17
+ # contents and no higher — an endpoint reaching past the cart into
18
+ # something the account holds asks for the account instead.
19
+ #
14
20
  # @return [Spree::Cart]
15
- def find_cart(include_completed: false)
21
+ def find_cart(include_completed: false, token: cart_token)
16
22
  @cart = resolve_cart(include_completed: include_completed)
17
- authorize_storefront_read!(@cart, token: cart_token)
23
+ authorize_storefront_read!(@cart, token: token)
18
24
  @cart
19
25
  end
20
26
 
@@ -159,11 +159,15 @@ module Spree
159
159
  "held=#{ability.permission_keys.join(',')}"
160
160
  end
161
161
 
162
+ render_missing_permission(required)
163
+ end
164
+
165
+ def render_missing_permission(permission)
162
166
  render_error(
163
167
  code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:access_denied],
164
- message: "Missing permission: #{required}",
168
+ message: "Missing permission: #{permission}",
165
169
  status: :forbidden,
166
- details: { required_permission: required }
170
+ details: { required_permission: permission }
167
171
  )
168
172
  end
169
173
 
@@ -61,8 +61,14 @@ module Spree
61
61
  Spree.api.admin_address_serializer
62
62
  end
63
63
 
64
+ def collection_includes
65
+ [:owner]
66
+ end
67
+
68
+ # The listing includes what the node inherits: a division's orders may ship to its headquarters' sites,
69
+ # so staff placing one must be able to see them — while writes stay with the node that owns the row.
64
70
  def scope
65
- @parent.addresses
71
+ action_name == 'index' ? @parent.address_book : @parent.addresses
66
72
  end
67
73
  end
68
74
  end
@@ -64,16 +64,37 @@ module Spree
64
64
  # store can't read or write custom fields on another store's records.
65
65
  # Users are intentionally global in Spree (`User.for_store` is a
66
66
  # no-op), so the store boundary for customer parents is enforced by
67
- # the `authorize!(:show, @parent)` ability check above.
67
+ # the `authorize!(:update, @parent)` ability check above.
68
+ #
69
+ # Fails closed: `Spree::Base.for_store` answers the bare class when the
70
+ # store has no association for the model, which would search every
71
+ # store's records.
68
72
  def parent_relation
69
73
  klass = parent_lookup.klass
70
- klass.respond_to?(:for_store) ? klass.for_store(current_store) : klass
74
+ return klass.where(id: current_store.id) if klass <= Spree::Store
75
+ return klass if klass <= Spree.customer_class
76
+
77
+ relation = klass.for_store(current_store)
78
+ raise ActiveRecord::RecordNotFound, 'Parent resource not found' if relation.equal?(klass)
79
+
80
+ relation
71
81
  end
72
82
 
73
83
  # Product-family parents have no scope pair of their own — their
74
84
  # custom fields are catalog data, gated by `products` like the rest
75
85
  # of the variant/option-type surface.
76
- SCOPE_OVERRIDES = { 'option_type' => :products, 'variant' => :products }.freeze
86
+ # Parents whose own controller is gated by a scope other than their
87
+ # plural name share that controller's scope.
88
+ SCOPE_OVERRIDES = {
89
+ 'option_type' => :products,
90
+ 'variant' => :products,
91
+ 'company' => :customers,
92
+ 'stock_transfer' => :stock,
93
+ 'purchase_order' => :purchasing,
94
+ 'payment_method' => :settings,
95
+ 'tax_rate' => :settings,
96
+ 'store' => :settings
97
+ }.freeze
77
98
 
78
99
  # Per-parent scope check: a key holding `write_products` may write a
79
100
  # product's custom fields, `write_orders` may write an order's, etc.
@@ -104,12 +125,13 @@ module Spree
104
125
  # code reloads — `enabled_resources` is captured at boot and its
105
126
  # class references go stale. Aliases `'customer'` because the route
106
127
  # uses `customer_id` while user_class.model_name.element is `'user'`,
107
- # and `'category'` because the routes expose taxons as categories
108
- # (5.5 rename) while the model's element is still `'taxon'`.
128
+ # `'category'` because the routes expose taxons as categories
129
+ # (5.5 rename) while the model's element is still `'taxon'`, and
130
+ # `'medium'` because Rails singularizes the `media` route to `medium_id`.
109
131
  def parent_route_map
110
132
  @parent_route_map ||= Spree.custom_fields.enabled_resources.each_with_object({}) do |klass, m|
111
133
  m[klass.model_name.element.to_s] = klass.name
112
- end.merge('customer' => Spree.customer_class.name, 'category' => 'Spree::Category')
134
+ end.merge('customer' => Spree.customer_class.name, 'category' => 'Spree::Category', 'medium' => 'Spree::Media')
113
135
  end
114
136
 
115
137
  # Returns the first segment whose `<segment>_id` is in the route,
@@ -117,8 +139,8 @@ module Spree
117
139
  # by both `set_parent` and `scoped_resource_name`.
118
140
  #
119
141
  # Read from the path only: a `store_id` or `refund_id` added to the
120
- # query string or body must not switch the parent to a type that is
121
- # never routed here and has no store scope to narrow it.
142
+ # query string or body must not switch the parent away from the record
143
+ # the route names.
122
144
  def parent_lookup
123
145
  return @parent_lookup if defined?(@parent_lookup)
124
146
 
@@ -69,6 +69,10 @@ module Spree
69
69
  Spree.api.admin_address_serializer
70
70
  end
71
71
 
72
+ def collection_includes
73
+ [:owner]
74
+ end
75
+
72
76
  private
73
77
 
74
78
  def address_attrs
@@ -248,12 +248,7 @@ module Spree
248
248
  missing = EXPORT_PERMISSIONS.reject { |permission| holds_permission?(permission) }
249
249
  return true if missing.empty?
250
250
 
251
- render_error(
252
- code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:access_denied],
253
- message: "Missing permission: #{missing.first}",
254
- status: :forbidden,
255
- details: { required_permission: missing.first }
256
- )
251
+ render_missing_permission(missing.first)
257
252
  false
258
253
  end
259
254
 
@@ -59,14 +59,7 @@ module Spree
59
59
  return unless params.key?(:customer_id)
60
60
  return @customer = nil if params[:customer_id].blank?
61
61
 
62
- unless holds_permission?('read_customers')
63
- return render_error(
64
- code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:access_denied],
65
- message: 'Missing permission: read_customers',
66
- status: :forbidden,
67
- details: { required_permission: 'read_customers' }
68
- )
69
- end
62
+ return render_missing_permission('read_customers') unless holds_permission?('read_customers')
70
63
 
71
64
  @customer = Spree.customer_class.
72
65
  accessible_by(current_ability, :show).
@@ -17,10 +17,12 @@ module Spree
17
17
  request_params :order do |body|
18
18
  body.external_references
19
19
  body.attribute :email, :string, example: 'customer@example.com'
20
- body.attribute :customer_id, :id
20
+ body.attribute :customer_id, :id,
21
+ nullable: true,
22
+ description: 'Null detaches the customer, leaving a guest order under its `email`.'
21
23
  body.attribute :use_customer_default_address, :boolean,
22
24
  only: :create,
23
- description: "With `customer_id`, copies the customer's saved billing and shipping addresses onto the order."
25
+ description: "Copies the saved default billing and shipping addresses onto the order. Sent with `company_id`, it takes that company's defaults, inherited from its parent nodes when the company holds none of its own; otherwise it takes the customer's."
24
26
  body.attribute :currency, :currency, example: 'USD'
25
27
  body.attribute :market_id, :id
26
28
  body.attribute :channel_id, :id, description: 'Defaults to the store primary channel when omitted.'
@@ -35,10 +37,10 @@ module Spree
35
37
  description: "The `signed_id` of the buyer's purchase order, from `files.upload()` with `visibility: 'private'`. Null removes the document the order carries."
36
38
  body.attribute :shipping_address_id, :id,
37
39
  only: :create,
38
- description: 'An existing address of the customer.'
40
+ description: "An existing address from the customer's address book, or from the address book of the company the order is for."
39
41
  body.attribute :billing_address_id, :id,
40
42
  only: :create,
41
- description: 'An existing address of the customer.'
43
+ description: "An existing address from the customer's address book, or from the address book of the company the order is for."
42
44
  body.attribute :preferred_stock_location_id, :id,
43
45
  description: "Stock location to prefer for fulfillment. Order Routing's built-in preferred-location rule ranks it first; routing falls back to the next rule when the location can't cover the cart."
44
46
  body.attribute :company_id, :id,
@@ -51,6 +53,8 @@ module Spree
51
53
  body.attribute :tags, :array, of: :string
52
54
  body.attribute :shipping_address, :object, shape: ADDRESS
53
55
  body.attribute :billing_address, :object, shape: ADDRESS
56
+ body.attribute :use_shipping, :boolean,
57
+ description: 'When true, copies the shipping address to the billing address.'
54
58
  body.attribute :items, :array,
55
59
  description: 'Line items to upsert, keyed by variant. An entry sets that variant\'s quantity, creating the line item when the order has none; `quantity: 0` removes it. Variants you do not send are left alone.' do |item|
56
60
  item.attribute :variant_id, :id, required: true
@@ -343,7 +347,9 @@ module Spree
343
347
  # Named rather than read from the action, since an upsert reaches
344
348
  # update from the create action.
345
349
  def order_update_params
346
- resolve_company_param(normalize_params(params.permit(*request_params_schema.permit_list(:update))))
350
+ permitted = resolve_company_param(normalize_params(params.permit(*request_params_schema.permit_list(:update))))
351
+ permitted[:customer_id] = resolve_customer&.id if permitted.key?(:customer_id)
352
+ permitted
347
353
  end
348
354
 
349
355
  # An incidental lookup like any other: resolved through the store so
@@ -27,9 +27,8 @@ module Spree
27
27
  body.attribute :length, :number, nullable: true
28
28
  body.attribute :width, :number, nullable: true
29
29
  body.attribute :height, :number, nullable: true
30
- body.attribute :dimensions_unit, :string,
30
+ body.attribute :dimensions_unit, :dimension_unit,
31
31
  nullable: true,
32
- choices: Spree::Variant::DIMENSION_UNITS,
33
32
  example: 'cm',
34
33
  description: "Unit the three dimensions are in; falls back to the store's unit system."
35
34
  body.attribute :weight, :number,
@@ -38,7 +37,7 @@ module Spree
38
37
  body.attribute :max_weight, :number,
39
38
  nullable: true,
40
39
  description: 'What the package can hold, not the same as its own weight.'
41
- body.attribute :weight_unit, :string, nullable: true, choices: Spree::Variant::WEIGHT_UNITS, example: 'kg'
40
+ body.attribute :weight_unit, :weight_unit, nullable: true, example: 'kg'
42
41
  body.attribute :default, :boolean,
43
42
  description: 'The box every parcel quote is built on. Setting this demotes whichever row held it.'
44
43
  body.attribute :metadata, :hash
@@ -7,11 +7,10 @@ module Spree
7
7
  scoped_resource :products
8
8
 
9
9
  request_params :variant do |body|
10
+ body.extends Spree::Api::V3::Admin::VariantRequestSchema
10
11
  body.external_references
11
- body.include_attributes Spree::Api::V3::VariantRequestSchema
12
- body.attribute :options, :array, of: Spree::Api::V3::VariantOptionRequestSchema,
13
- required: true, on: :create,
14
- description: 'One pair per option type the variant takes part in (size, color). Option types and values are created when missing. On update, omit to leave the option values untouched.'
12
+ body.require_attributes :options, on: :create
13
+ body.describe_attribute :options, 'One pair per option type the variant takes part in (size, color). Option types and values are created when missing. On update, omit to leave the option values untouched.'
15
14
  end
16
15
 
17
16
  protected
@@ -77,10 +77,8 @@ module Spree
77
77
  body.attribute :variants, :array,
78
78
  as: 'ProductVariantParams',
79
79
  description: 'Every purchasable attribute (sku, prices, stock, weight, dimensions) lives on variants. An entry without `options` upserts onto the default variant of a simple product; pass `id` to update an existing variant.' do |variant|
80
+ variant.extends Spree::Api::V3::Admin::VariantRequestSchema
80
81
  variant.attribute :id, :id
81
- variant.include_attributes Spree::Api::V3::VariantRequestSchema
82
- variant.attribute :options, :array, of: Spree::Api::V3::VariantOptionRequestSchema,
83
- description: 'One pair per option type the variant takes part in (size, color). Option types and values are created when missing.'
84
82
  end
85
83
  end
86
84
 
@@ -291,6 +289,10 @@ module Spree
291
289
  Spree.product_update_workflow
292
290
  end
293
291
 
292
+ def nested_permitted_models
293
+ { media: Spree::Media, variants: Spree::Variant }
294
+ end
295
+
294
296
  # Use SearchProvider::Database for collection to handle price/best_selling
295
297
  # sorting correctly (counts before sorting, avoiding PG/Mobility issues).
296
298
  def collection
@@ -9,7 +9,7 @@ module Spree
9
9
  body.attribute :name, :string, example: 'My Store'
10
10
  body.attribute :admin_locale, :string, example: 'en'
11
11
  body.attribute :timezone, :string, example: 'Europe/Berlin'
12
- body.attribute :weight_unit, :string, choices: Spree::Variant::WEIGHT_UNITS
12
+ body.attribute :weight_unit, :weight_unit
13
13
  body.attribute :unit_system, :string, choices: Spree::Store::UNIT_SYSTEMS
14
14
  body.attribute :storefront_access, :string,
15
15
  choices: Spree::Channel::Gating::STOREFRONT_ACCESS,
@@ -5,6 +5,30 @@ module Spree
5
5
  class VariantsController < ResourceController
6
6
  scoped_resource :products
7
7
 
8
+ SALES_CONTEXT = Spree::Api::V3::RequestSchema.shape('VariantSalesContextParams') do |query|
9
+ query.attribute :order_id, :id,
10
+ description: 'A draft order to sell to: lists only what its buyer can be sold on its channel, ' \
11
+ 'priced in its market. Needs `read_orders`, and cannot be combined with the fields below.'
12
+ query.attribute :market_id, :id,
13
+ description: 'Prices in this market and lists only variants with a price in its currency.'
14
+ query.attribute :channel_id, :id,
15
+ description: 'Lists only variants published on this channel. When a market, customer, company or ' \
16
+ "currency is given without it, the store default channel applies; an order uses its own."
17
+ query.attribute :customer_id, :id,
18
+ description: "Narrows to the customer's catalogs and prices as they would pay. Needs `read_customers`."
19
+ query.attribute :company_id, :id,
20
+ description: "Narrows to the company's catalogs and prices under its agreement. Needs `read_customers`."
21
+ query.attribute :currency, :currency,
22
+ description: 'Lists only variants with a price in this currency. ' \
23
+ "With `market_id` it must be the market's currency."
24
+ end
25
+ SALES_CONTEXT_PARAMS = (SALES_CONTEXT.fields.keys - [:order_id]).freeze
26
+
27
+ # A draft order's item picker asks with the order's market, channel and
28
+ # buyer, so it lists only what that buyer can be sold there, priced as
29
+ # they would pay.
30
+ before_action :load_sales_context, only: :index
31
+
8
32
  protected
9
33
 
10
34
  def model_class
@@ -19,7 +43,21 @@ module Spree
19
43
  # relation from scratch dropped `scope_includes`, so every listed
20
44
  # variant fell back to per-row price and stock queries.
21
45
  def scope
22
- super.eligible.accessible_by(current_ability, ability_action_for_request)
46
+ variants = super.eligible.accessible_by(current_ability, ability_action_for_request)
47
+ @sales_context ? variants.where(id: @sales_context.sellable_variants.select(:id)) : variants
48
+ end
49
+
50
+ def serializer_params
51
+ return super unless @sales_context
52
+
53
+ super.merge(
54
+ currency: @sales_context.currency,
55
+ user: @sales_context.customer,
56
+ company: @sales_context.company,
57
+ market: @sales_context.market,
58
+ channel: @sales_context.channel,
59
+ country: @sales_context.country
60
+ )
23
61
  end
24
62
 
25
63
  # `active_stock_reservations` rides along with the stock levels:
@@ -33,6 +71,63 @@ module Spree
33
71
  { primary_media: [{ attachment_attachment: :blob }, { poster_attachment: :blob }] }
34
72
  ]
35
73
  end
74
+
75
+ private
76
+
77
+ def load_sales_context
78
+ requested = SALES_CONTEXT_PARAMS.select { |key| params[key].present? }
79
+ return if requested.empty? && params[:order_id].blank?
80
+
81
+ permission = required_sales_context_permission
82
+ return render_missing_permission(permission) if permission && !holds_permission?(permission)
83
+
84
+ if params[:order_id].present?
85
+ return refuse_order_with_context(requested) if requested.any?
86
+
87
+ @sales_context = Spree::SalesContext.from_order(context_order)
88
+ else
89
+ @sales_context = requested_sales_context
90
+ render_errors(@sales_context.errors) unless @sales_context.valid?
91
+ end
92
+ end
93
+
94
+ # An order already fixes its market, channel, buyer and currency, so
95
+ # naming them too could only disagree with it.
96
+ def refuse_order_with_context(requested)
97
+ render_error(
98
+ code: Spree::Api::V3::ErrorHandler::ERROR_CODES[:parameter_invalid],
99
+ message: I18n.t('spree.api.errors.order_sales_context_exclusive', params: requested.join(', ')),
100
+ status: :unprocessable_content,
101
+ details: { order_id: requested.map(&:to_s) }
102
+ )
103
+ end
104
+
105
+ def required_sales_context_permission
106
+ return 'read_orders' if params[:order_id].present?
107
+
108
+ 'read_customers' if params[:customer_id].present? || params[:company_id].present?
109
+ end
110
+
111
+ def requested_sales_context
112
+ Spree::SalesContext.new(
113
+ store: current_store,
114
+ market: find_requested(current_store.markets, :market_id),
115
+ channel: find_requested(current_store.channels, :channel_id),
116
+ customer: find_requested(
117
+ Spree.customer_class.for_store(current_store).accessible_by(current_ability, :show), :customer_id
118
+ ),
119
+ company: find_requested(current_store.companies, :company_id),
120
+ currency: params[:currency]
121
+ )
122
+ end
123
+
124
+ def find_requested(relation, key)
125
+ relation.find_by_prefix_id!(params[key]) if params[key].present?
126
+ end
127
+
128
+ def context_order
129
+ current_store.orders.accessible_by(current_ability, :show).find_by_prefix_id!(params[:order_id])
130
+ end
36
131
  end
37
132
  end
38
133
  end
@@ -44,15 +44,26 @@ module Spree
44
44
  # and the merchant would approve on the wrong one.
45
45
  def consent_url
46
46
  query = params.permit(*FORWARDED_PARAMETERS).to_h.compact_blank.to_query
47
- base = Spree::Stores::DashboardUrl.call(store: consent_store)
47
+ base = Spree::Stores::DashboardUrl.call(store: current_store)
48
48
 
49
- "#{base}/#{consent_store.prefixed_id}/oauth/authorize?#{query}"
49
+ "#{base}/#{current_store.prefixed_id}/oauth/authorize?#{query}"
50
50
  end
51
51
 
52
- # @return [Spree::Store]
53
- def consent_store
54
- @consent_store ||=
55
- Spree::OauthApplication.find_by(uid: params[:client_id])&.store || current_store
52
+ # The client's store wins over the hostname's, and is looked up
53
+ # before the hostname is resolved at all: resolving it assigns the
54
+ # default store to Spree::Current, and an app scoping queries by
55
+ # that store would then never find another store's client.
56
+ #
57
+ # @return [Spree::Store, nil]
58
+ def current_store
59
+ @current_store ||= client_application&.store || super
60
+ end
61
+
62
+ # @return [Spree::OauthApplication, nil]
63
+ def client_application
64
+ return if params[:client_id].blank?
65
+
66
+ Spree::OauthApplication.find_by(uid: params[:client_id])
56
67
  end
57
68
  end
58
69
  end
@@ -559,7 +559,17 @@ module Spree
559
559
  # declared on the model. Override +resource_permitted_attributes+ rather
560
560
  # than this, so the extension union is not lost.
561
561
  def permitted_attributes
562
- resource_permitted_attributes + model_additional_permitted_attributes
562
+ with_nested_additional_permitted_attributes(resource_permitted_attributes) + model_additional_permitted_attributes
563
+ end
564
+
565
+ # Nested lists this resource writes, keyed by param name, mapped to the
566
+ # model each entry becomes — +{ media: Spree::Media }+ lets an
567
+ # extension's +Spree::Media.additional_permitted_attributes+ ride the
568
+ # product's +media+ entries, not only the media endpoint.
569
+ #
570
+ # @return [Hash{Symbol => Class}]
571
+ def nested_permitted_models
572
+ {}
563
573
  end
564
574
 
565
575
  # The resource's writable attributes: its `request_params`
@@ -580,13 +590,33 @@ module Spree
580
590
  def model_additional_permitted_attributes
581
591
  return [] unless respond_to?(:model_class, true)
582
592
 
583
- model = model_class
584
- return [] unless model.respond_to?(:additional_permitted_attributes)
585
-
586
- Array(model.additional_permitted_attributes)
593
+ additional_permitted_attributes_for(model_class)
587
594
  rescue NotImplementedError
588
595
  []
589
596
  end
597
+
598
+ # What extensions declared writable on +model+. `Spree.base_class` is
599
+ # host-overridable, so the class_attribute may not be there at all.
600
+ #
601
+ # @param model [Class]
602
+ # @return [Array]
603
+ def additional_permitted_attributes_for(model)
604
+ Array(model.try(:additional_permitted_attributes))
605
+ end
606
+
607
+ def with_nested_additional_permitted_attributes(attributes)
608
+ return attributes if nested_permitted_models.empty?
609
+
610
+ attributes.map do |attribute|
611
+ next attribute unless attribute.is_a?(Hash)
612
+
613
+ attribute.to_h do |name, nested|
614
+ model = nested_permitted_models[name.to_sym]
615
+ additional = model ? additional_permitted_attributes_for(model) : []
616
+ [name, additional.any? && nested.is_a?(Array) ? nested + additional : nested]
617
+ end
618
+ end
619
+ end
590
620
  end
591
621
  end
592
622
  end
@@ -28,12 +28,12 @@ module Spree
28
28
  body.attribute :length, :number, nullable: true
29
29
  body.attribute :width, :number, nullable: true
30
30
  body.attribute :height, :number, nullable: true
31
- body.attribute :dimensions_unit, :string, nullable: true, choices: Spree::Variant::DIMENSION_UNITS
31
+ body.attribute :dimensions_unit, :dimension_unit, nullable: true
32
32
  body.attribute :weight, :number,
33
33
  nullable: true,
34
34
  description: "The empty package's own weight, added to every quote."
35
35
  body.attribute :max_weight, :number, nullable: true
36
- body.attribute :weight_unit, :string, nullable: true, choices: Spree::Variant::WEIGHT_UNITS
36
+ body.attribute :weight_unit, :weight_unit, nullable: true
37
37
  body.attribute :default, :boolean,
38
38
  description: "Makes this the box this seller's parcels are quoted with. One default per seller, so it never displaces the marketplace's."
39
39
  body.attribute :metadata, :hash, nullable: true