spree_api 5.4.3 → 5.5.0.rc1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Rakefile +19 -0
- data/app/controllers/concerns/spree/api/v3/admin/auth_cookies.rb +62 -0
- data/app/controllers/concerns/spree/api/v3/admin/subclassed_resource.rb +149 -0
- data/app/controllers/concerns/spree/api/v3/admin_authentication.rb +54 -0
- data/app/controllers/concerns/spree/api/v3/bulk_operations.rb +103 -0
- data/app/controllers/concerns/spree/api/v3/channel_resolution.rb +60 -0
- data/app/controllers/concerns/spree/api/v3/error_handler.rb +4 -0
- data/app/controllers/concerns/spree/api/v3/params_normalizer.rb +84 -0
- data/app/controllers/concerns/spree/api/v3/scoped_authorization.rb +88 -0
- data/app/controllers/concerns/spree/api/v3/store/search_provider_support.rb +35 -1
- data/app/controllers/spree/api/v3/admin/admin_users_controller.rb +97 -0
- data/app/controllers/spree/api/v3/admin/allowed_origins_controller.rb +25 -0
- data/app/controllers/spree/api/v3/admin/api_keys_controller.rb +55 -0
- data/app/controllers/spree/api/v3/admin/auth_controller.rb +134 -0
- data/app/controllers/spree/api/v3/admin/base_controller.rb +3 -17
- data/app/controllers/spree/api/v3/admin/categories_controller.rb +25 -0
- data/app/controllers/spree/api/v3/admin/channels_controller.rb +65 -0
- data/app/controllers/spree/api/v3/admin/countries_controller.rb +38 -0
- data/app/controllers/spree/api/v3/admin/coupon_codes_controller.rb +33 -0
- data/app/controllers/spree/api/v3/admin/custom_field_definitions_controller.rb +34 -0
- data/app/controllers/spree/api/v3/admin/custom_fields_controller.rb +108 -0
- data/app/controllers/spree/api/v3/admin/customer_groups_controller.rb +31 -0
- data/app/controllers/spree/api/v3/admin/customers/addresses_controller.rb +88 -0
- data/app/controllers/spree/api/v3/admin/customers/credit_cards_controller.rb +31 -0
- data/app/controllers/spree/api/v3/admin/customers/store_credits_controller.rb +93 -0
- data/app/controllers/spree/api/v3/admin/customers_controller.rb +119 -0
- data/app/controllers/spree/api/v3/admin/dashboard_controller.rb +44 -0
- data/app/controllers/spree/api/v3/admin/direct_uploads_controller.rb +40 -0
- data/app/controllers/spree/api/v3/admin/exports_controller.rb +89 -0
- data/app/controllers/spree/api/v3/admin/gift_card_batches_controller.rb +31 -0
- data/app/controllers/spree/api/v3/admin/gift_cards_controller.rb +33 -0
- data/app/controllers/spree/api/v3/admin/invitation_acceptances_controller.rb +138 -0
- data/app/controllers/spree/api/v3/admin/invitations_controller.rb +70 -0
- data/app/controllers/spree/api/v3/admin/markets_controller.rb +42 -0
- data/app/controllers/spree/api/v3/admin/me_controller.rb +69 -0
- data/app/controllers/spree/api/v3/admin/media_controller.rb +119 -0
- data/app/controllers/spree/api/v3/admin/option_types_controller.rb +34 -0
- data/app/controllers/spree/api/v3/admin/orders/adjustments_controller.rb +27 -0
- data/app/controllers/spree/api/v3/admin/orders/base_controller.rb +26 -0
- data/app/controllers/spree/api/v3/admin/orders/fulfillments_controller.rb +104 -0
- data/app/controllers/spree/api/v3/admin/orders/gift_cards_controller.rb +79 -0
- data/app/controllers/spree/api/v3/admin/orders/items_controller.rb +92 -0
- data/app/controllers/spree/api/v3/admin/orders/payments_controller.rb +90 -0
- data/app/controllers/spree/api/v3/admin/orders/refunds_controller.rb +53 -0
- data/app/controllers/spree/api/v3/admin/orders/store_credits_controller.rb +59 -0
- data/app/controllers/spree/api/v3/admin/orders_controller.rb +190 -0
- data/app/controllers/spree/api/v3/admin/payment_methods_controller.rb +73 -0
- data/app/controllers/spree/api/v3/admin/price_lists_controller.rb +156 -0
- data/app/controllers/spree/api/v3/admin/prices_controller.rb +129 -0
- data/app/controllers/spree/api/v3/admin/products/variants_controller.rb +48 -0
- data/app/controllers/spree/api/v3/admin/products_controller.rb +237 -0
- data/app/controllers/spree/api/v3/admin/promotion_actions_controller.rb +78 -0
- data/app/controllers/spree/api/v3/admin/promotion_rules_controller.rb +56 -0
- data/app/controllers/spree/api/v3/admin/promotions_controller.rb +78 -0
- data/app/controllers/spree/api/v3/admin/resource_controller.rb +29 -11
- data/app/controllers/spree/api/v3/admin/roles_controller.rb +29 -0
- data/app/controllers/spree/api/v3/admin/stock_items_controller.rb +35 -0
- data/app/controllers/spree/api/v3/admin/stock_locations_controller.rb +36 -0
- data/app/controllers/spree/api/v3/admin/stock_reservations_controller.rb +29 -0
- data/app/controllers/spree/api/v3/admin/stock_transfers_controller.rb +75 -0
- data/app/controllers/spree/api/v3/admin/store_controller.rb +53 -0
- data/app/controllers/spree/api/v3/admin/store_credit_categories_controller.rb +21 -0
- data/app/controllers/spree/api/v3/admin/tags_controller.rb +51 -0
- data/app/controllers/spree/api/v3/admin/tax_categories_controller.rb +21 -0
- data/app/controllers/spree/api/v3/admin/variants_controller.rb +33 -0
- data/app/controllers/spree/api/v3/admin/webhook_deliveries_controller.rb +49 -0
- data/app/controllers/spree/api/v3/admin/webhook_endpoints_controller.rb +75 -0
- data/app/controllers/spree/api/v3/resource_controller.rb +90 -8
- data/app/controllers/spree/api/v3/store/auth_controller.rb +8 -28
- data/app/controllers/spree/api/v3/store/base_controller.rb +6 -0
- data/app/controllers/spree/api/v3/store/carts_controller.rb +1 -0
- data/app/controllers/spree/api/v3/store/customers_controller.rb +6 -0
- data/app/controllers/spree/api/v3/store/newsletter_subscribers_controller.rb +77 -0
- data/app/controllers/spree/api/v3/store/products/filters_controller.rb +2 -2
- data/app/controllers/spree/api/v3/store/products_controller.rb +3 -3
- data/app/controllers/spree/api/v3/store/resource_controller.rb +10 -2
- data/app/jobs/spree/webhook_delivery_job.rb +5 -0
- data/app/models/spree/api_key_ability.rb +16 -0
- data/app/serializers/spree/api/v3/admin/address_serializer.rb +2 -6
- data/app/serializers/spree/api/v3/admin/adjustment_serializer.rb +3 -15
- data/app/serializers/spree/api/v3/admin/admin_user_serializer.rb +19 -3
- data/app/serializers/spree/api/v3/admin/allowed_origin_serializer.rb +2 -6
- data/app/serializers/spree/api/v3/admin/api_key_serializer.rb +42 -0
- data/app/serializers/spree/api/v3/admin/category_serializer.rb +4 -3
- data/app/serializers/spree/api/v3/admin/channel_serializer.rb +15 -0
- data/app/serializers/spree/api/v3/admin/country_serializer.rb +1 -1
- data/app/serializers/spree/api/v3/admin/coupon_code_serializer.rb +30 -0
- data/app/serializers/spree/api/v3/admin/credit_card_serializer.rb +4 -2
- data/app/serializers/spree/api/v3/admin/custom_field_definition_serializer.rb +21 -0
- data/app/serializers/spree/api/v3/admin/custom_field_serializer.rb +8 -3
- data/app/serializers/spree/api/v3/admin/customer_group_serializer.rb +27 -0
- data/app/serializers/spree/api/v3/admin/customer_serializer.rb +58 -2
- data/app/serializers/spree/api/v3/admin/dashboard_analytics_serializer.rb +143 -0
- data/app/serializers/spree/api/v3/admin/export_serializer.rb +40 -0
- data/app/serializers/spree/api/v3/admin/fulfillment_serializer.rb +2 -6
- data/app/serializers/spree/api/v3/admin/{asset_serializer.rb → gift_card_batch_serializer.rb} +1 -1
- data/app/serializers/spree/api/v3/admin/gift_card_serializer.rb +39 -4
- data/app/serializers/spree/api/v3/admin/invitation_serializer.rb +64 -0
- data/app/serializers/spree/api/v3/admin/line_item_serializer.rb +4 -16
- data/app/serializers/spree/api/v3/admin/media_serializer.rb +24 -2
- data/app/serializers/spree/api/v3/admin/option_type_serializer.rb +4 -1
- data/app/serializers/spree/api/v3/admin/option_value_serializer.rb +4 -1
- data/app/serializers/spree/api/v3/admin/order_serializer.rb +21 -6
- data/app/serializers/spree/api/v3/admin/payment_method_serializer.rb +11 -2
- data/app/serializers/spree/api/v3/admin/payment_serializer.rb +2 -6
- data/app/serializers/spree/api/v3/admin/payment_source_serializer.rb +4 -1
- data/app/serializers/spree/api/v3/admin/price_list_serializer.rb +51 -0
- data/app/serializers/spree/api/v3/admin/price_rule_serializer.rb +55 -0
- data/app/serializers/spree/api/v3/admin/price_serializer.rb +4 -0
- data/app/serializers/spree/api/v3/admin/product_publication_serializer.rb +11 -0
- data/app/serializers/spree/api/v3/admin/product_serializer.rb +34 -10
- data/app/serializers/spree/api/v3/admin/promotion_action_serializer.rb +71 -0
- data/app/serializers/spree/api/v3/admin/promotion_rule_serializer.rb +85 -0
- data/app/serializers/spree/api/v3/admin/promotion_serializer.rb +41 -0
- data/app/serializers/spree/api/v3/admin/refund_serializer.rb +4 -2
- data/app/serializers/spree/api/v3/admin/role_serializer.rb +17 -0
- data/app/serializers/spree/api/v3/admin/stock_item_serializer.rb +16 -1
- data/app/serializers/spree/api/v3/admin/stock_location_serializer.rb +11 -2
- data/app/serializers/spree/api/v3/admin/stock_reservation_serializer.rb +46 -0
- data/app/serializers/spree/api/v3/admin/stock_transfer_serializer.rb +37 -0
- data/app/serializers/spree/api/v3/admin/store_credit_category_serializer.rb +19 -0
- data/app/serializers/spree/api/v3/admin/store_credit_serializer.rb +11 -5
- data/app/serializers/spree/api/v3/admin/store_serializer.rb +55 -0
- data/app/serializers/spree/api/v3/admin/tax_category_serializer.rb +4 -2
- data/app/serializers/spree/api/v3/admin/variant_serializer.rb +37 -6
- data/app/serializers/spree/api/v3/admin/webhook_delivery_serializer.rb +45 -0
- data/app/serializers/spree/api/v3/admin/webhook_endpoint_serializer.rb +69 -0
- data/app/serializers/spree/api/v3/channel_serializer.rb +14 -0
- data/app/serializers/spree/api/v3/custom_field_serializer.rb +9 -10
- data/app/serializers/spree/api/v3/customer_serializer.rb +5 -0
- data/app/serializers/spree/api/v3/market_serializer.rb +2 -1
- data/app/serializers/spree/api/v3/media_serializer.rb +8 -6
- data/app/serializers/spree/api/v3/order_serializer.rb +6 -1
- data/app/serializers/spree/api/v3/payment_method_serializer.rb +11 -2
- data/app/serializers/spree/api/v3/product_publication_serializer.rb +22 -0
- data/app/serializers/spree/api/v3/product_serializer.rb +6 -1
- data/app/serializers/spree/api/v3/stock_reservation_serializer.rb +10 -0
- data/config/locales/en.yml +2 -0
- data/config/routes.rb +235 -1
- data/lib/spree/api/configuration.rb +2 -2
- data/lib/spree/api/dependencies.rb +25 -1
- data/lib/spree/api/openapi/path_sorter.rb +126 -0
- data/lib/spree/api/openapi/schema_helper.rb +185 -6
- metadata +96 -8
- data/app/serializers/spree/api/v3/admin/shipping_category_serializer.rb +0 -14
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Api
|
|
3
|
+
module V3
|
|
4
|
+
module Admin
|
|
5
|
+
# Admin CRUD for `Spree::PriceList`, plus the lifecycle transitions
|
|
6
|
+
# (`activate` / `deactivate`) and the spreadsheet's data feed
|
|
7
|
+
# (`prices`).
|
|
8
|
+
#
|
|
9
|
+
# Everything writable on the list — name, schedule, match policy,
|
|
10
|
+
# product membership (`product_ids: [...]`), nested rules
|
|
11
|
+
# (`rules: [...]`), and individual price overrides
|
|
12
|
+
# (`prices: [...]`) — flows through the regular PATCH payload, so
|
|
13
|
+
# the SPA saves the entire editor in one round-trip. No separate
|
|
14
|
+
# add_products / remove_products / add_rule / bulk_update_prices
|
|
15
|
+
# endpoints.
|
|
16
|
+
#
|
|
17
|
+
# Scoped under the `products` API-key scope — price lists are a
|
|
18
|
+
# product/pricing concern; we don't introduce a separate
|
|
19
|
+
# `read_price_lists` scope.
|
|
20
|
+
class PriceListsController < ResourceController
|
|
21
|
+
scoped_resource :products
|
|
22
|
+
|
|
23
|
+
# The base ResourceController limits `set_resource` to
|
|
24
|
+
# `show/update/destroy`. We need it on the custom member
|
|
25
|
+
# actions below too, so swap in our own filter — Rails keys
|
|
26
|
+
# before_actions by method name, so this would otherwise
|
|
27
|
+
# *replace* the parent's narrower filter and break the standard
|
|
28
|
+
# actions. Wrapping it under a different name keeps both.
|
|
29
|
+
before_action :load_member_resource, only: [:activate, :deactivate, :prices]
|
|
30
|
+
|
|
31
|
+
# GET /api/v3/admin/price_lists/price_rule_types
|
|
32
|
+
#
|
|
33
|
+
# Returns `[{ type, label, description, preference_schema }]`
|
|
34
|
+
# for every registered subclass in `Spree.pricing.rules`. The
|
|
35
|
+
# SPA uses this to build the "Add rule" picker + render a
|
|
36
|
+
# generic preferences form per subclass. Rules themselves are
|
|
37
|
+
# not a separate REST resource — they ride along on the price
|
|
38
|
+
# list's PATCH body via `rules: [...]`.
|
|
39
|
+
def price_rule_types
|
|
40
|
+
authorize! :read, Spree::PriceRule
|
|
41
|
+
render json: { data: Spree::PriceRule.subclasses_with_preference_schema }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# PATCH /api/v3/admin/price_lists/:id/activate
|
|
45
|
+
#
|
|
46
|
+
# State transition: draft|inactive → active (or → scheduled when
|
|
47
|
+
# `starts_at` is in the future). Mirrors the old Rails admin's
|
|
48
|
+
# "Activate" button which automatically scheduled future lists.
|
|
49
|
+
def activate
|
|
50
|
+
authorize! :update, @resource
|
|
51
|
+
event = @resource.starts_at.present? && @resource.starts_at.future? ? :schedule : :activate
|
|
52
|
+
|
|
53
|
+
if @resource.send(event)
|
|
54
|
+
render json: serialize_resource(@resource)
|
|
55
|
+
else
|
|
56
|
+
render_validation_error(@resource.errors)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# PATCH /api/v3/admin/price_lists/:id/deactivate
|
|
61
|
+
def deactivate
|
|
62
|
+
authorize! :update, @resource
|
|
63
|
+
|
|
64
|
+
if @resource.deactivate
|
|
65
|
+
render json: serialize_resource(@resource)
|
|
66
|
+
else
|
|
67
|
+
render_validation_error(@resource.errors)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# GET /api/v3/admin/price_lists/:id/prices
|
|
72
|
+
#
|
|
73
|
+
# The spreadsheet editor's data source. Returns every Price row
|
|
74
|
+
# in this list (filtered by `?currency=`), eager-loading
|
|
75
|
+
# `variant.product` + option values so each cell can render
|
|
76
|
+
# product name, variant options and SKU without N+1.
|
|
77
|
+
def prices
|
|
78
|
+
authorize! :read, @resource
|
|
79
|
+
currency = params[:currency].presence || current_store.default_currency
|
|
80
|
+
prices = @resource.prices
|
|
81
|
+
.includes(variant: [:product, { option_values: :option_type }])
|
|
82
|
+
.where(currency: currency)
|
|
83
|
+
.joins(variant: :product)
|
|
84
|
+
.order(Arel.sql("#{Spree::Product.table_name}.name ASC"))
|
|
85
|
+
.order(Arel.sql("#{Spree::Variant.table_name}.position ASC"))
|
|
86
|
+
|
|
87
|
+
render json: {
|
|
88
|
+
data: prices.map { |p| serialize_price(p) },
|
|
89
|
+
meta: { currency: currency, count: prices.size }
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
protected
|
|
94
|
+
|
|
95
|
+
def model_class
|
|
96
|
+
Spree::PriceList
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def serializer_class
|
|
100
|
+
Spree.api.admin_price_list_serializer
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def scope
|
|
104
|
+
super.for_store(current_store)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def permitted_params
|
|
108
|
+
normalize_params(
|
|
109
|
+
params.permit(
|
|
110
|
+
:name, :description, :position,
|
|
111
|
+
:starts_at, :ends_at, :match_policy,
|
|
112
|
+
product_ids: [],
|
|
113
|
+
rules: [:id, :type, { preferences: {} }],
|
|
114
|
+
prices: [:id, :variant_id, :currency, :amount, :compare_at_amount]
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
# Loads the record without the action-derived authorization
|
|
122
|
+
# `set_resource` runs (which would check `:activate` /
|
|
123
|
+
# `:deactivate` / `:prices` — actions that abilities don't
|
|
124
|
+
# grant). The per-action methods below explicitly call
|
|
125
|
+
# `authorize!` with the standard action that ability rules
|
|
126
|
+
# actually mention (`:update` / `:read`).
|
|
127
|
+
def load_member_resource
|
|
128
|
+
@resource = find_resource
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Hand-rolled flat shape for the spreadsheet — keeps the payload
|
|
132
|
+
# narrow (no nested variant/product/option_value objects) and
|
|
133
|
+
# avoids paying for the admin Price serializer when we only need
|
|
134
|
+
# ~6 fields per row. The grouping the UI does (rows → product
|
|
135
|
+
# header) is driven entirely off `product_id`/`product_name`.
|
|
136
|
+
def serialize_price(price)
|
|
137
|
+
variant = price.variant
|
|
138
|
+
product = variant.product
|
|
139
|
+
{
|
|
140
|
+
id: price.prefixed_id,
|
|
141
|
+
variant_id: variant.prefixed_id,
|
|
142
|
+
product_id: product.prefixed_id,
|
|
143
|
+
product_name: product.name,
|
|
144
|
+
variant_label: variant.options_text.presence,
|
|
145
|
+
sku: variant.sku,
|
|
146
|
+
currency: price.currency,
|
|
147
|
+
amount: price.amount&.to_s,
|
|
148
|
+
compare_at_amount: price.compare_at_amount&.to_s
|
|
149
|
+
}
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Api
|
|
3
|
+
module V3
|
|
4
|
+
module Admin
|
|
5
|
+
# Admin CRUD for `Spree::Price`. Covers both base prices
|
|
6
|
+
# (`price_list_id: nil`) and price-list overrides under one
|
|
7
|
+
# resource, filtered via Ransack predicates on the index.
|
|
8
|
+
class PricesController < ResourceController
|
|
9
|
+
include Spree::Api::V3::BulkOperations
|
|
10
|
+
|
|
11
|
+
scoped_resource :products
|
|
12
|
+
|
|
13
|
+
before_action :require_ids!, only: [:bulk_destroy]
|
|
14
|
+
before_action :require_prices!, only: [:bulk_upsert]
|
|
15
|
+
|
|
16
|
+
# Bulk-upserts prices on the unique-key triple
|
|
17
|
+
# `(variant_id, currency, price_list_id)`.
|
|
18
|
+
#
|
|
19
|
+
# @return [void]
|
|
20
|
+
def bulk_upsert
|
|
21
|
+
authorize! :create, Spree::Price
|
|
22
|
+
authorize! :update, Spree::Price
|
|
23
|
+
|
|
24
|
+
rows = Array(params[:prices]).map { |row| decode_price_row(row) }
|
|
25
|
+
invalid = rows.each_with_index.filter_map do |row, idx|
|
|
26
|
+
missing = %i[variant_id currency].reject { |k| row[k].present? }
|
|
27
|
+
{ index: idx, missing: missing } if missing.any?
|
|
28
|
+
end
|
|
29
|
+
if invalid.any?
|
|
30
|
+
return render_error(
|
|
31
|
+
code: 'invalid_prices',
|
|
32
|
+
message: 'Each row must include variant_id and currency.',
|
|
33
|
+
status: :unprocessable_content,
|
|
34
|
+
details: { rows: invalid }
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
result = Spree::Prices::BulkUpsert.call(rows: rows)
|
|
39
|
+
render json: result.value
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Soft-deletes the listed prices.
|
|
43
|
+
#
|
|
44
|
+
# @return [void]
|
|
45
|
+
def bulk_destroy
|
|
46
|
+
authorize! :destroy, Spree::Price
|
|
47
|
+
|
|
48
|
+
destroy_scope = scope.where(id: decode_ids(params[:ids]))
|
|
49
|
+
destroyed = destroy_scope.count(&:destroy)
|
|
50
|
+
|
|
51
|
+
render json: { price_count: destroyed }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
protected
|
|
55
|
+
|
|
56
|
+
def model_class
|
|
57
|
+
Spree::Price
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def serializer_class
|
|
61
|
+
Spree.api.admin_price_serializer
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def collection_includes
|
|
65
|
+
{
|
|
66
|
+
variant: [
|
|
67
|
+
:tax_category,
|
|
68
|
+
:prices,
|
|
69
|
+
product: :tax_category,
|
|
70
|
+
option_values: :option_type,
|
|
71
|
+
stock_items: [:stock_location, :active_stock_reservations]
|
|
72
|
+
]
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Disabled: Ransack's default `result(distinct: true)` makes
|
|
77
|
+
# Postgres reject `sort=variant_product_name` because the order
|
|
78
|
+
# column isn't in the DISTINCT select list. The store scope
|
|
79
|
+
# already guarantees one Price row per result.
|
|
80
|
+
def collection_distinct?
|
|
81
|
+
false
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def permitted_params
|
|
85
|
+
normalize_params(
|
|
86
|
+
params.permit(:variant_id, :currency, :amount, :compare_at_amount, :price_list_id)
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def bulk_record_count_key
|
|
93
|
+
:price_count
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def require_prices!
|
|
97
|
+
return if params.key?(:prices)
|
|
98
|
+
|
|
99
|
+
render_error(
|
|
100
|
+
code: 'missing_prices',
|
|
101
|
+
message: 'prices is required (send an empty array to no-op).',
|
|
102
|
+
status: :unprocessable_content
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def decode_price_row(row)
|
|
107
|
+
row = row.respond_to?(:to_unsafe_h) ? row.to_unsafe_h : row.to_h
|
|
108
|
+
row = row.with_indifferent_access
|
|
109
|
+
|
|
110
|
+
{
|
|
111
|
+
id: decode_id(row[:id]),
|
|
112
|
+
variant_id: decode_id(row[:variant_id]),
|
|
113
|
+
price_list_id: row.key?(:price_list_id) ? decode_id(row[:price_list_id]) : nil,
|
|
114
|
+
currency: row[:currency],
|
|
115
|
+
amount: row[:amount],
|
|
116
|
+
compare_at_amount: row[:compare_at_amount]
|
|
117
|
+
}.compact
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def decode_id(value)
|
|
121
|
+
return nil if value.blank?
|
|
122
|
+
|
|
123
|
+
Spree::PrefixedId.prefixed_id?(value) ? Spree::PrefixedId.decode_prefixed_id(value) : value
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Api
|
|
3
|
+
module V3
|
|
4
|
+
module Admin
|
|
5
|
+
module Products
|
|
6
|
+
class VariantsController < ResourceController
|
|
7
|
+
scoped_resource :products
|
|
8
|
+
|
|
9
|
+
protected
|
|
10
|
+
|
|
11
|
+
def model_class
|
|
12
|
+
Spree::Variant
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def serializer_class
|
|
16
|
+
Spree.api.admin_variant_serializer
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def set_parent
|
|
20
|
+
@parent = current_store.products.find_by_prefix_id!(params[:product_id])
|
|
21
|
+
authorize!(:show, @parent)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def parent_association
|
|
25
|
+
:variants_including_master
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def scope_includes
|
|
29
|
+
[:prices, stock_items: :stock_location]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def permitted_params
|
|
33
|
+
params.permit(
|
|
34
|
+
:sku, :barcode, :price, :compare_at_price,
|
|
35
|
+
:cost_price, :cost_currency,
|
|
36
|
+
:weight, :height, :width, :depth, :weight_unit, :dimensions_unit,
|
|
37
|
+
:track_inventory, :tax_category_id, :position,
|
|
38
|
+
options: [:name, :value],
|
|
39
|
+
prices: [:amount, :compare_at_amount, :currency],
|
|
40
|
+
stock_items: [:stock_location_id, :count_on_hand, :backorderable]
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Api
|
|
3
|
+
module V3
|
|
4
|
+
module Admin
|
|
5
|
+
class ProductsController < ResourceController
|
|
6
|
+
include Spree::Api::V3::BulkOperations
|
|
7
|
+
|
|
8
|
+
scoped_resource :products
|
|
9
|
+
|
|
10
|
+
before_action :require_ids!, only: [
|
|
11
|
+
:bulk_status_update,
|
|
12
|
+
:bulk_add_to_categories,
|
|
13
|
+
:bulk_remove_from_categories,
|
|
14
|
+
:bulk_add_to_channels,
|
|
15
|
+
:bulk_remove_from_channels,
|
|
16
|
+
:bulk_destroy
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
# POST /api/v3/admin/products/:id/clone
|
|
20
|
+
def clone
|
|
21
|
+
@resource = find_resource
|
|
22
|
+
authorize!(:create, @resource)
|
|
23
|
+
|
|
24
|
+
result = @resource.duplicate
|
|
25
|
+
if result.success?
|
|
26
|
+
render json: serialize_resource(result.value), status: :created
|
|
27
|
+
else
|
|
28
|
+
render_service_error(result.error)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# POST /api/v3/admin/products/bulk_status_update
|
|
33
|
+
# Body: { ids: [...], status: 'draft' | 'active' | 'archived' }
|
|
34
|
+
def bulk_status_update
|
|
35
|
+
authorize! :update, model_class
|
|
36
|
+
|
|
37
|
+
unless Spree::Product::STATUSES.include?(params[:status].to_s)
|
|
38
|
+
return render_error(
|
|
39
|
+
code: 'invalid_status',
|
|
40
|
+
message: Spree.t(:invalid_status, scope: 'errors.messages', default: 'Invalid status'),
|
|
41
|
+
status: :unprocessable_content
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
count = bulk_collection.update_all(status: params[:status], updated_at: Time.current)
|
|
46
|
+
# `update_all` skips `after_commit`, so the search index won't refresh on its own.
|
|
47
|
+
bulk_collection.each(&:enqueue_search_index)
|
|
48
|
+
|
|
49
|
+
render json: { product_count: count, status: params[:status] }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# POST /api/v3/admin/products/bulk_add_to_categories
|
|
53
|
+
# Body: { ids: [...], category_ids: [...] }
|
|
54
|
+
def bulk_add_to_categories
|
|
55
|
+
apply_categories(Spree::Taxons::AddProducts)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# POST /api/v3/admin/products/bulk_remove_from_categories
|
|
59
|
+
# Body: { ids: [...], category_ids: [...] }
|
|
60
|
+
def bulk_remove_from_categories
|
|
61
|
+
apply_categories(Spree::Taxons::RemoveProducts)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# POST /api/v3/admin/products/bulk_add_to_channels
|
|
65
|
+
# Body: { ids: [...], channel_ids: [...] }
|
|
66
|
+
def bulk_add_to_channels
|
|
67
|
+
authorize! :update, model_class
|
|
68
|
+
|
|
69
|
+
channels = scoped_channels
|
|
70
|
+
product_ids = bulk_collection.distinct.ids
|
|
71
|
+
channels.find_each { |channel| channel.add_products(product_ids) }
|
|
72
|
+
|
|
73
|
+
render json: { product_count: product_ids.size, channel_count: channels.size }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# POST /api/v3/admin/products/bulk_remove_from_channels
|
|
77
|
+
# Body: { ids: [...], channel_ids: [...] }
|
|
78
|
+
def bulk_remove_from_channels
|
|
79
|
+
authorize! :update, model_class
|
|
80
|
+
|
|
81
|
+
channels = scoped_channels
|
|
82
|
+
product_ids = bulk_collection.distinct.ids
|
|
83
|
+
removed = channels.sum { |channel| channel.remove_products(product_ids) }
|
|
84
|
+
|
|
85
|
+
render json: { product_count: product_ids.size, channel_count: channels.size, removed: removed }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# DELETE /api/v3/admin/products/bulk_destroy
|
|
89
|
+
# Body: { ids: [...] }
|
|
90
|
+
def bulk_destroy
|
|
91
|
+
authorize! :destroy, model_class
|
|
92
|
+
|
|
93
|
+
# Scope by `:destroy` rather than reusing `bulk_collection`
|
|
94
|
+
# (which is `:update`-scoped). Otherwise an admin with update
|
|
95
|
+
# rights but no destroy rights could soft-delete records.
|
|
96
|
+
destroy_scope = model_class.for_store(current_store)
|
|
97
|
+
.accessible_by(current_ability, :destroy)
|
|
98
|
+
.where(id: decode_ids(params[:ids]))
|
|
99
|
+
destroyed = destroy_scope.count(&:destroy)
|
|
100
|
+
|
|
101
|
+
render json: { product_count: destroyed }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
protected
|
|
105
|
+
|
|
106
|
+
def model_class
|
|
107
|
+
Spree::Product
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def serializer_class
|
|
111
|
+
Spree.api.admin_product_serializer
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def scope_includes
|
|
115
|
+
[
|
|
116
|
+
:tax_category,
|
|
117
|
+
product_publications: :channel,
|
|
118
|
+
primary_media: [attachment_attachment: :blob],
|
|
119
|
+
master: [:prices, stock_items: [:stock_location, :active_stock_reservations]],
|
|
120
|
+
variants: [:prices, stock_items: [:stock_location, :active_stock_reservations]]
|
|
121
|
+
]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Use SearchProvider::Database for collection to handle price/best_selling
|
|
125
|
+
# sorting correctly (counts before sorting, avoiding PG/Mobility issues).
|
|
126
|
+
def collection
|
|
127
|
+
return @collection if @collection.present?
|
|
128
|
+
|
|
129
|
+
filters = params[:q]&.to_unsafe_h || params[:q] || {}
|
|
130
|
+
# Decode Stripe-style prefixed IDs in `*_id_in`/`id_eq`/etc. so SPA
|
|
131
|
+
# filters can pass `prod_…` keys; the search provider expects raw
|
|
132
|
+
# IDs because it goes straight to Ransack on the underlying scope.
|
|
133
|
+
filters = decode_prefixed_id_predicates(filters)
|
|
134
|
+
# `q[search]` is the global text-search predicate; pass it through
|
|
135
|
+
# the provider's `query` arg so it invokes `Product.search` rather
|
|
136
|
+
# than being treated as a Ransack predicate (which gets stripped
|
|
137
|
+
# by the provider's filter sanitizer).
|
|
138
|
+
query = filters['search'] || filters[:search]
|
|
139
|
+
|
|
140
|
+
result = search_provider.search_and_filter(
|
|
141
|
+
scope: scope.includes(collection_includes).preload_associations_lazily.accessible_by(current_ability, :show),
|
|
142
|
+
query: query,
|
|
143
|
+
filters: filters,
|
|
144
|
+
sort: sort_param,
|
|
145
|
+
page: page,
|
|
146
|
+
limit: limit
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
@pagy = result.pagy
|
|
150
|
+
@collection = result.products
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def permitted_params
|
|
154
|
+
# Product is purely a catalog grouping in API v3. All purchasable
|
|
155
|
+
# attributes (sku, barcode, price, weight, dimensions, stock,
|
|
156
|
+
# track_inventory) live on variants. See
|
|
157
|
+
# docs/plans/6.0-remove-master-variant.md.
|
|
158
|
+
#
|
|
159
|
+
# Top-level `prices` is a convenience for simple (no-options)
|
|
160
|
+
# products: the merchant doesn't need to know the master variant
|
|
161
|
+
# exists, so they ship prices alongside name/status and the
|
|
162
|
+
# `Spree::Product#prices=` setter forwards them to the master.
|
|
163
|
+
params.permit(
|
|
164
|
+
:name, :description, :slug, :status,
|
|
165
|
+
:meta_title, :meta_description, :meta_keywords,
|
|
166
|
+
:tax_category_id,
|
|
167
|
+
:promotionable, :digital,
|
|
168
|
+
tags: [],
|
|
169
|
+
category_ids: [],
|
|
170
|
+
metadata: {},
|
|
171
|
+
prices: [:amount, :compare_at_amount, :currency],
|
|
172
|
+
# Inline custom field values keyed by definition id. The model
|
|
173
|
+
# setter (`Spree::Metafields#custom_fields=`) validates each
|
|
174
|
+
# entry against its definition. We permit `value` as both a
|
|
175
|
+
# scalar AND `value: {}` (any-shape Hash/Array) — Strong
|
|
176
|
+
# Parameters merges them so JSON metafields can ship parsed
|
|
177
|
+
# objects while text/number/boolean ship scalars.
|
|
178
|
+
custom_fields: [:id, :custom_field_definition_id, :value, value: {}],
|
|
179
|
+
# Inline media. Entries with `id` patch an existing asset
|
|
180
|
+
# (alt, position, variant_ids). Entries with `signed_id` create
|
|
181
|
+
# + attach a fresh upload. Lets the dashboard ship media changes
|
|
182
|
+
# alongside the rest of the product form. See
|
|
183
|
+
# `Spree::Product#media=`.
|
|
184
|
+
media: [:id, :signed_id, :alt, :position, :type, variant_ids: []],
|
|
185
|
+
product_publications: [:id, :channel_id, :published_at, :unpublished_at],
|
|
186
|
+
variants: [
|
|
187
|
+
:id, :sku, :barcode,
|
|
188
|
+
:cost_price, :cost_currency,
|
|
189
|
+
:weight, :height, :width, :depth, :weight_unit, :dimensions_unit,
|
|
190
|
+
:track_inventory, :tax_category_id, :position,
|
|
191
|
+
options: [:name, :value],
|
|
192
|
+
prices: [:amount, :compare_at_amount, :currency],
|
|
193
|
+
stock_items: [:id, :stock_location_id, :count_on_hand, :backorderable]
|
|
194
|
+
]
|
|
195
|
+
)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
private
|
|
199
|
+
|
|
200
|
+
def search_provider
|
|
201
|
+
@search_provider ||= Spree::SearchProvider::Database.new(current_store)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Mirrors `Spree::Admin::ProductsController#after_bulk_tags_change`:
|
|
205
|
+
# tag changes can flip automatic-taxon matches, and `Tags::Bulk*`
|
|
206
|
+
# touch records via `touch_all` (which skips `after_commit`), so the
|
|
207
|
+
# search index needs an explicit kick.
|
|
208
|
+
def after_bulk_tags_change
|
|
209
|
+
Spree::Product.bulk_auto_match_taxons(current_store, bulk_collection.ids)
|
|
210
|
+
bulk_collection.each(&:enqueue_search_index)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def bulk_record_count_key
|
|
214
|
+
:product_count
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def apply_categories(service)
|
|
218
|
+
authorize! :update, model_class
|
|
219
|
+
|
|
220
|
+
category_ids = decode_ids(params[:category_ids])
|
|
221
|
+
categories = current_store.taxons.accessible_by(current_ability, :update).where(id: category_ids)
|
|
222
|
+
|
|
223
|
+
service.call(taxons: categories, products: bulk_collection)
|
|
224
|
+
Spree::Product.bulk_auto_match_taxons(current_store, bulk_collection.ids)
|
|
225
|
+
|
|
226
|
+
render json: { product_count: bulk_collection.size, category_count: categories.size }
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def scoped_channels
|
|
230
|
+
channel_ids = decode_ids(params[:channel_ids])
|
|
231
|
+
current_store.channels.accessible_by(current_ability, :manage).where(id: channel_ids)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
module Spree
|
|
2
|
+
module Api
|
|
3
|
+
module V3
|
|
4
|
+
module Admin
|
|
5
|
+
# CRUD for `Spree::PromotionAction` STI subclasses. Each action is
|
|
6
|
+
# nested under a promotion; the create body picks the subclass via
|
|
7
|
+
# `type` and the typed-preferences shape (`preferences: {...}`).
|
|
8
|
+
class PromotionActionsController < ResourceController
|
|
9
|
+
include Spree::Api::V3::Admin::SubclassedResource
|
|
10
|
+
|
|
11
|
+
scoped_resource :promotions
|
|
12
|
+
|
|
13
|
+
subclassed_via -> { Spree.promotions.actions },
|
|
14
|
+
unknown_type_error: 'unknown_promotion_action_type'
|
|
15
|
+
|
|
16
|
+
def types
|
|
17
|
+
authorize! :read, model_class
|
|
18
|
+
|
|
19
|
+
render json: { data: model_class.subclasses_with_preference_schema }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Returns the calculator subclasses registered for the given action
|
|
23
|
+
# `type` (e.g. `?type=Spree::Promotion::Actions::CreateAdjustment`).
|
|
24
|
+
# Each entry includes the calculator's class name, label,
|
|
25
|
+
# description, and preference schema so the SPA can render the
|
|
26
|
+
# picker + nested fields without hardcoding the calculator list.
|
|
27
|
+
def calculators
|
|
28
|
+
authorize! :read, model_class
|
|
29
|
+
|
|
30
|
+
klass = resolve_subclass(params[:type])
|
|
31
|
+
return render_unknown_type unless klass && klass.respond_to?(:calculators)
|
|
32
|
+
|
|
33
|
+
data = klass.calculators.map do |calc|
|
|
34
|
+
{
|
|
35
|
+
type: calc.to_s,
|
|
36
|
+
label: calc.respond_to?(:description) ? calc.description : calc.to_s.demodulize.titleize,
|
|
37
|
+
preference_schema: calc.respond_to?(:serialized_preference_schema) ? calc.serialized_preference_schema : []
|
|
38
|
+
}
|
|
39
|
+
end.sort_by { |entry| entry[:label].to_s }
|
|
40
|
+
|
|
41
|
+
render json: { data: data }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
protected
|
|
45
|
+
|
|
46
|
+
def model_class
|
|
47
|
+
Spree::PromotionAction
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def serializer_class
|
|
51
|
+
Spree.api.admin_promotion_action_serializer
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def permitted_params
|
|
55
|
+
params.permit(:type, preferences: {})
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def set_parent
|
|
59
|
+
return if %w[types calculators].include?(action_name)
|
|
60
|
+
|
|
61
|
+
@parent = Spree::Promotion.accessible_by(current_ability, :show)
|
|
62
|
+
.find_by_prefix_id!(params[:promotion_id])
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def parent_association
|
|
66
|
+
:promotion_actions
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def build_subclassed_resource(klass, attrs)
|
|
72
|
+
klass.new(attrs.merge(promotion: @parent))
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|