spree_core 5.4.3 → 5.5.0.rc2

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 (239) hide show
  1. checksums.yaml +4 -4
  2. data/app/helpers/spree/base_helper.rb +0 -82
  3. data/app/helpers/spree/currency_helper.rb +0 -12
  4. data/app/helpers/spree/products_helper.rb +0 -8
  5. data/app/jobs/spree/base_job.rb +18 -0
  6. data/app/jobs/spree/events/subscriber_job.rb +2 -1
  7. data/app/jobs/spree/exports/generate_job.rb +11 -0
  8. data/app/jobs/spree/images/save_from_url_job.rb +23 -8
  9. data/app/jobs/spree/imports/assign_tags_job.rb +11 -0
  10. data/app/jobs/spree/imports/base_job.rb +15 -0
  11. data/app/jobs/spree/imports/create_categories_job.rb +37 -0
  12. data/app/jobs/spree/imports/create_rows_job.rb +1 -3
  13. data/app/jobs/spree/imports/process_group_job.rb +8 -6
  14. data/app/jobs/spree/imports/process_rows_job.rb +1 -3
  15. data/app/jobs/spree/media/migrate_product_assets_job.rb +83 -0
  16. data/app/jobs/spree/products/refresh_metrics_job.rb +15 -4
  17. data/app/jobs/spree/reports/generate_job.rb +11 -0
  18. data/app/jobs/spree/search_provider/index_job.rb +5 -1
  19. data/app/jobs/spree/search_provider/remove_job.rb +4 -0
  20. data/app/jobs/spree/stock_reservations/expire_job.rb +11 -0
  21. data/app/models/concerns/spree/calculated_adjustments.rb +34 -1
  22. data/app/models/concerns/spree/display_on.rb +31 -0
  23. data/app/models/concerns/spree/metafields.rb +167 -5
  24. data/app/models/concerns/spree/preference_schema.rb +191 -0
  25. data/app/models/concerns/spree/prefixed_id.rb +94 -11
  26. data/app/models/concerns/spree/product_scopes.rb +36 -17
  27. data/app/models/concerns/spree/ransackable_attributes.rb +5 -1
  28. data/app/models/concerns/spree/search_indexable.rb +8 -7
  29. data/app/models/concerns/spree/searchable.rb +11 -2
  30. data/app/models/concerns/spree/stores/channels.rb +20 -0
  31. data/app/models/concerns/spree/stores/markets.rb +21 -5
  32. data/app/models/concerns/spree/typed_associations.rb +120 -0
  33. data/app/models/concerns/spree/user_methods.rb +71 -12
  34. data/app/models/spree/ability.rb +4 -117
  35. data/app/models/spree/api_key.rb +60 -0
  36. data/app/models/spree/asset.rb +28 -5
  37. data/app/models/spree/authentication/strategy_registry.rb +72 -0
  38. data/app/models/spree/base.rb +18 -1
  39. data/app/models/spree/channel.rb +159 -0
  40. data/app/models/spree/country.rb +2 -0
  41. data/app/models/spree/current.rb +7 -3
  42. data/app/models/spree/custom_field.rb +9 -0
  43. data/app/models/spree/custom_field_definition.rb +7 -0
  44. data/app/models/spree/customer_group.rb +8 -2
  45. data/app/models/spree/export.rb +45 -3
  46. data/app/models/spree/exports/coupon_codes.rb +4 -0
  47. data/app/models/spree/exports/newsletter_subscribers.rb +4 -0
  48. data/app/models/spree/exports/product_translations.rb +4 -0
  49. data/app/models/spree/gateway.rb +25 -0
  50. data/app/models/spree/gift_card.rb +1 -1
  51. data/app/models/spree/gift_card_batch.rb +4 -1
  52. data/app/models/spree/import.rb +5 -0
  53. data/app/models/spree/import_row.rb +12 -0
  54. data/app/models/spree/line_item.rb +6 -1
  55. data/app/models/spree/market.rb +32 -1
  56. data/app/models/spree/metafield.rb +38 -0
  57. data/app/models/spree/metafield_definition.rb +29 -6
  58. data/app/models/spree/metafields/json.rb +10 -0
  59. data/app/models/spree/newsletter_subscriber.rb +19 -3
  60. data/app/models/spree/option_type.rb +48 -7
  61. data/app/models/spree/order/checkout.rb +3 -3
  62. data/app/models/spree/order.rb +102 -6
  63. data/app/models/spree/order_approval.rb +19 -0
  64. data/app/models/spree/order_cancellation.rb +19 -0
  65. data/app/models/spree/order_routing/has_strategy_preference.rb +28 -0
  66. data/app/models/spree/order_routing/rules/default_location.rb +16 -0
  67. data/app/models/spree/order_routing/rules/minimize_splits.rb +45 -0
  68. data/app/models/spree/order_routing/rules/preferred_location.rb +22 -0
  69. data/app/models/spree/order_routing/strategy/base.rb +47 -0
  70. data/app/models/spree/order_routing/strategy/legacy.rb +33 -0
  71. data/app/models/spree/order_routing/strategy/reducer.rb +68 -0
  72. data/app/models/spree/order_routing/strategy/rules.rb +83 -0
  73. data/app/models/spree/order_routing_rule.rb +75 -0
  74. data/app/models/spree/permission_sets/configuration_management.rb +16 -0
  75. data/app/models/spree/permission_sets/product_display.rb +2 -0
  76. data/app/models/spree/permission_sets/product_management.rb +2 -0
  77. data/app/models/spree/price.rb +14 -1
  78. data/app/models/spree/price_list.rb +129 -17
  79. data/app/models/spree/price_rule.rb +11 -1
  80. data/app/models/spree/price_rules/customer_group_rule.rb +15 -1
  81. data/app/models/spree/price_rules/market_rule.rb +16 -1
  82. data/app/models/spree/price_rules/user_rule.rb +21 -2
  83. data/app/models/spree/product/channels.rb +149 -0
  84. data/app/models/spree/product/legacy_multi_store_support.rb +40 -0
  85. data/app/models/spree/product/slugs.rb +1 -1
  86. data/app/models/spree/product.rb +172 -31
  87. data/app/models/spree/product_publication.rb +43 -0
  88. data/app/models/spree/promotion/actions/create_adjustment.rb +4 -0
  89. data/app/models/spree/promotion/actions/create_item_adjustments.rb +4 -0
  90. data/app/models/spree/promotion/actions/create_line_items.rb +32 -14
  91. data/app/models/spree/promotion/rules/country.rb +40 -18
  92. data/app/models/spree/promotion/rules/customer_group.rb +10 -1
  93. data/app/models/spree/promotion/rules/product.rb +4 -0
  94. data/app/models/spree/promotion/rules/taxon.rb +24 -1
  95. data/app/models/spree/promotion/rules/user.rb +21 -0
  96. data/app/models/spree/promotion/rules/user_logged_in.rb +6 -0
  97. data/app/models/spree/promotion.rb +22 -1
  98. data/app/models/spree/promotion_action.rb +17 -11
  99. data/app/models/spree/promotion_rule.rb +17 -18
  100. data/app/models/spree/search_provider/meilisearch.rb +12 -2
  101. data/app/models/spree/stock/availability_validator.rb +1 -1
  102. data/app/models/spree/stock/quantifier.rb +89 -9
  103. data/app/models/spree/stock_item.rb +36 -0
  104. data/app/models/spree/stock_location.rb +52 -0
  105. data/app/models/spree/stock_reservation.rb +38 -0
  106. data/app/models/spree/stock_reservations/insufficient_stock_error.rb +12 -0
  107. data/app/models/spree/store.rb +18 -72
  108. data/app/models/spree/store_credit.rb +0 -8
  109. data/app/models/spree/store_product.rb +11 -23
  110. data/app/models/spree/taxon.rb +0 -5
  111. data/app/models/spree/user_identity.rb +1 -2
  112. data/app/models/spree/variant.rb +132 -18
  113. data/app/models/spree/variant_media.rb +46 -0
  114. data/app/models/spree/webhook_delivery.rb +1 -1
  115. data/app/models/spree/webhook_endpoint.rb +24 -0
  116. data/app/models/spree/wished_item.rb +0 -13
  117. data/app/presenters/spree/csv/product_variant_presenter.rb +23 -3
  118. data/app/presenters/spree/search_provider/product_presenter.rb +11 -4
  119. data/app/presenters/spree/variant_presenter.rb +4 -3
  120. data/app/services/spree/addresses/update.rb +6 -8
  121. data/app/services/spree/cart/add_item.rb +10 -0
  122. data/app/services/spree/cart/empty.rb +2 -0
  123. data/app/services/spree/cart/remove_line_item.rb +10 -0
  124. data/app/services/spree/cart/remove_out_of_stock_items.rb +1 -1
  125. data/app/services/spree/cart/set_quantity.rb +10 -0
  126. data/app/services/spree/carts/complete.rb +1 -0
  127. data/app/services/spree/carts/create.rb +1 -0
  128. data/app/services/spree/carts/update.rb +18 -2
  129. data/app/services/spree/carts/upsert_items.rb +6 -6
  130. data/app/services/spree/imports/row_processors/customer.rb +4 -1
  131. data/app/services/spree/imports/row_processors/product_variant.rb +95 -57
  132. data/app/services/spree/newsletter/link_user.rb +53 -0
  133. data/app/services/spree/newsletter/subscribe.rb +31 -9
  134. data/app/services/spree/orders/approve.rb +27 -6
  135. data/app/services/spree/orders/build_shipments.rb +29 -0
  136. data/app/services/spree/orders/cancel.rb +34 -3
  137. data/app/services/spree/orders/complete.rb +53 -0
  138. data/app/services/spree/orders/create.rb +156 -0
  139. data/app/services/spree/orders/update.rb +51 -0
  140. data/app/services/spree/orders/upsert_items.rb +70 -0
  141. data/app/services/spree/prices/bulk_upsert.rb +201 -0
  142. data/app/services/spree/products/duplicator.rb +1 -1
  143. data/app/services/spree/products/prepare_nested_attributes.rb +2 -30
  144. data/app/services/spree/sample_data/loader.rb +30 -0
  145. data/app/services/spree/stock_reservations/extend.rb +19 -0
  146. data/app/services/spree/stock_reservations/release.rb +12 -0
  147. data/app/services/spree/stock_reservations/reserve.rb +103 -0
  148. data/app/services/spree/taxons/remove_products.rb +7 -1
  149. data/app/subscribers/spree/product_metrics_subscriber.rb +3 -7
  150. data/app/views/spree/invitation_mailer/invitation_email.html.erb +4 -0
  151. data/config/locales/en.yml +28 -10
  152. data/config/routes.rb +9 -0
  153. data/db/migrate/20260429000001_create_spree_order_cancellations.rb +25 -0
  154. data/db/migrate/20260429000002_create_spree_order_approvals.rb +22 -0
  155. data/db/migrate/20260429000003_add_status_to_spree_orders.rb +6 -0
  156. data/db/migrate/20260429000004_add_scopes_to_spree_api_keys.rb +11 -0
  157. data/db/migrate/20260501000001_create_spree_stock_reservations.rb +19 -0
  158. data/db/migrate/20260507162651_create_spree_variant_media.rb +23 -0
  159. data/db/migrate/20260508175303_add_pickup_to_spree_stock_locations.rb +12 -0
  160. data/db/migrate/20260508204040_create_spree_channels.rb +18 -0
  161. data/db/migrate/20260508204041_create_spree_order_routing_rules.rb +18 -0
  162. data/db/migrate/20260508204042_add_preferred_stock_location_to_spree_orders.rb +5 -0
  163. data/db/migrate/20260508204043_add_channel_id_to_spree_orders.rb +10 -0
  164. data/db/migrate/20260511000001_backfill_status_on_spree_orders.rb +57 -0
  165. data/db/migrate/20260515000001_add_store_id_to_spree_newsletter_subscribers.rb +25 -0
  166. data/db/migrate/20260529000001_add_unique_index_to_spree_price_rules.rb +41 -0
  167. data/db/migrate/20260529000002_add_unique_index_to_spree_promotion_rules.rb +37 -0
  168. data/db/migrate/20260601000001_create_spree_product_publications.rb +14 -0
  169. data/db/migrate/20260601000002_add_store_id_to_spree_products.rb +16 -0
  170. data/db/migrate/20260602000001_add_default_to_spree_channels.rb +14 -0
  171. data/db/migrate/20260612000001_change_spree_user_identities_info_to_jsonb.rb +13 -0
  172. data/db/sample_data/channels.rb +12 -0
  173. data/db/sample_data/orders.rb +1 -1
  174. data/db/sample_data/products.csv +212 -212
  175. data/lib/generators/spree/api_resource/api_resource_generator.rb +353 -0
  176. data/lib/generators/spree/api_resource/templates/admin_controller.rb.tt +23 -0
  177. data/lib/generators/spree/api_resource/templates/admin_controller_spec.rb.tt +59 -0
  178. data/lib/generators/spree/api_resource/templates/admin_serializer.rb.tt +11 -0
  179. data/lib/generators/spree/api_resource/templates/factory.rb.tt +26 -0
  180. data/lib/generators/spree/api_resource/templates/store_aliased_serializer.rb.tt +12 -0
  181. data/lib/generators/spree/api_resource/templates/store_controller.rb.tt +31 -0
  182. data/lib/generators/spree/api_resource/templates/store_controller_spec.rb.tt +61 -0
  183. data/lib/generators/spree/api_resource/templates/store_serializer.rb.tt +17 -0
  184. data/lib/generators/spree/controller_decorator/controller_decorator_generator.rb +66 -0
  185. data/lib/generators/spree/controller_decorator/templates/controller_decorator.rb.tt +25 -0
  186. data/lib/generators/spree/model/model_generator.rb +73 -7
  187. data/lib/generators/spree/model/templates/create_table_migration.rb.tt +40 -0
  188. data/lib/generators/spree/model/templates/model.rb.tt +28 -2
  189. data/lib/generators/spree/subscriber/subscriber_generator.rb +116 -0
  190. data/lib/generators/spree/subscriber/templates/subscriber.rb.tt +17 -0
  191. data/lib/generators/spree/subscriber/templates/subscriber_spec.rb.tt +9 -0
  192. data/lib/spree/core/configuration.rb +7 -0
  193. data/lib/spree/core/controller_helpers/auth.rb +0 -12
  194. data/lib/spree/core/controller_helpers/currency.rb +0 -17
  195. data/lib/spree/core/controller_helpers/order.rb +0 -19
  196. data/lib/spree/core/dependencies.rb +5 -2
  197. data/lib/spree/core/engine.rb +54 -7
  198. data/lib/spree/core/permission_configuration.rb +15 -0
  199. data/lib/spree/core/preferences/masking.rb +47 -0
  200. data/lib/spree/core/preferences/preferable_class_methods.rb +7 -1
  201. data/lib/spree/core/version.rb +1 -1
  202. data/lib/spree/core.rb +56 -5
  203. data/lib/spree/permitted_attributes.rb +9 -7
  204. data/lib/spree/testing_support/factories/address_factory.rb +16 -9
  205. data/lib/spree/testing_support/factories/api_key_factory.rb +1 -0
  206. data/lib/spree/testing_support/factories/channel_factory.rb +8 -0
  207. data/lib/spree/testing_support/factories/line_item_factory.rb +2 -8
  208. data/lib/spree/testing_support/factories/newsletter_subscriber_factory.rb +2 -0
  209. data/lib/spree/testing_support/factories/product_factory.rb +16 -7
  210. data/lib/spree/testing_support/factories/product_publication_factory.rb +6 -0
  211. data/lib/spree/testing_support/factories/refresh_token_factory.rb +15 -0
  212. data/lib/spree/testing_support/factories/stock_location_factory.rb +2 -2
  213. data/lib/spree/testing_support/factories/stock_reservation_factory.rb +31 -0
  214. data/lib/spree/testing_support/factories/variant_factory.rb +3 -3
  215. data/lib/spree/testing_support/order_walkthrough.rb +1 -1
  216. data/lib/spree/testing_support/store.rb +10 -0
  217. data/lib/spree/upgrades/5_4_to_5_5/manifest.yml +53 -0
  218. data/lib/tasks/channels.rake +94 -0
  219. data/lib/tasks/cli.rake +2 -1
  220. data/lib/tasks/core.rake +1 -0
  221. data/lib/tasks/media.rake +27 -0
  222. data/lib/tasks/products.rake +4 -6
  223. data/lib/tasks/publications.rake +60 -0
  224. data/lib/tasks/upgrade.rake +211 -0
  225. metadata +87 -18
  226. data/app/finders/spree/variants/visible_finder.rb +0 -23
  227. data/app/paginators/spree/shared/paginate.rb +0 -30
  228. data/app/presenters/spree/filters/price_presenter.rb +0 -23
  229. data/app/presenters/spree/filters/price_range_presenter.rb +0 -30
  230. data/app/presenters/spree/filters/quantified_price_range_presenter.rb +0 -45
  231. data/app/presenters/spree/product_summary_presenter.rb +0 -27
  232. data/app/presenters/spree/variants/options_presenter.rb +0 -82
  233. data/app/services/spree/classifications/reposition.rb +0 -23
  234. data/app/sorters/spree/orders/sort.rb +0 -10
  235. data/lib/spree/core/controller_helpers/common.rb +0 -14
  236. data/lib/spree/core/token_generator.rb +0 -23
  237. data/lib/spree/database_type_utilities.rb +0 -22
  238. data/lib/spree/testing_support/bar_ability.rb +0 -14
  239. data/lib/spree/testing_support/factories/store_product_factory.rb +0 -6
@@ -0,0 +1,353 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../model/model_generator'
4
+
5
+ module Spree
6
+ # spree:api_resource — scaffold a complete v3-conformant API resource on
7
+ # top of `spree:model`.
8
+ #
9
+ # bin/rails g spree:api_resource Brand name:string:uniq active:boolean --writable
10
+ #
11
+ # Inherits from Spree::ModelGenerator (model + migration with Spree
12
+ # conventions: prefixed IDs, spree_-prefixed tables, null: false, optional
13
+ # acts_as_paranoid + Spree::Metafields). Adds on top:
14
+ #
15
+ # - Store + Admin controllers (managed — overwrite on re-run)
16
+ # - Store + Admin serializers (managed — overwrite on re-run)
17
+ # - Factory (managed — overwrite on re-run)
18
+ # - Controller specs (managed — overwrite on re-run)
19
+ # - Routes (idempotent inject between sentinels)
20
+ #
21
+ # Owned-once contract: if the model file already exists, the generator
22
+ # leaves it (and the migration) alone — domain code is yours after
23
+ # creation. Re-running adds/updates API surfaces only.
24
+ #
25
+ # TypeScript types and Zod schemas regenerate automatically via the
26
+ # Lefthook pre-commit hook when a serializer file is staged.
27
+ #
28
+ # See docs/plans/spree-dev-cli-and-generators.md (Track 3) for the
29
+ # owned-once / managed-forever / append-only contract.
30
+ class ApiResourceGenerator < Spree::ModelGenerator
31
+ # API-specific templates live alongside this generator. Parent's
32
+ # templates (model.rb.tt, create_table_migration.rb.tt) are inherited
33
+ # via Spree::ModelGenerator.source_paths.
34
+ def self.source_paths
35
+ [File.expand_path('templates', __dir__), *superclass.source_paths]
36
+ end
37
+
38
+ class_option :store,
39
+ type: :boolean,
40
+ default: true,
41
+ desc: 'Generate Store API controller + serializer'
42
+
43
+ class_option :admin,
44
+ type: :boolean,
45
+ default: true,
46
+ desc: 'Generate Admin API controller + serializer'
47
+
48
+ class_option :store_name,
49
+ type: :string,
50
+ desc: 'Expose Store API under a different name (e.g. Brand → Discount)'
51
+
52
+ class_option :writable,
53
+ type: :boolean,
54
+ default: false,
55
+ desc: 'Make the Store API writable (create/update/destroy)'
56
+
57
+ class_option :skip_routes,
58
+ type: :boolean,
59
+ default: false,
60
+ desc: "Don't inject routes into spree/api/config/routes.rb"
61
+
62
+ class_option :skip_specs,
63
+ type: :boolean,
64
+ default: false,
65
+ desc: "Don't generate controller specs"
66
+
67
+ # api_resource generates its own controller specs and factory directly,
68
+ # so we remove the parent's spec/fixture hooks. Otherwise Rails would
69
+ # invoke rspec (boots Rails → PendingMigrationError on the freshly-
70
+ # created migration; or "[not found]" warning on re-runs).
71
+ remove_hook_for :test_framework
72
+ remove_hook_for :fixture_replacement if hooks[:fixture_replacement]
73
+
74
+ # Re-runs are part of the contract (owned-once gating skips the model
75
+ # and migration). Rails' NamedBase collision check would otherwise abort
76
+ # the second run with "Spree::Brand is already used in your application".
77
+ class_option :skip_collision_check, type: :boolean, default: true, desc: false
78
+
79
+ desc 'Scaffold a complete v3-conformant API resource (model + migration + controllers + serializers + routes + factory + specs)'
80
+
81
+ # --- Owned-once gating ---
82
+ #
83
+ # Thor's parent commands run BEFORE subclass commands (commands merge
84
+ # from superclass first). So we can't snapshot model existence in a
85
+ # subclass action and have parent's create_model_file see it. We
86
+ # capture state in initialize, before any action runs.
87
+
88
+ def initialize(*args)
89
+ super
90
+ @model_existed_before_run = File.exist?(File.join(destination_root, model_file_destination))
91
+ end
92
+
93
+ # Override parent: skip if the model file already exists. Re-running
94
+ # never overwrites domain code.
95
+ def create_model_file
96
+ if @model_existed_before_run
97
+ say_status :skip, "model #{model_file_destination} (owned-once; already exists)", :yellow
98
+ return
99
+ end
100
+ super
101
+ end
102
+
103
+ # Override parent: skip if the model existed before this run. Migrations
104
+ # are append-only — schema changes get a separate migration:
105
+ # pnpm exec spree rails g migration AddFooToBar foo:string
106
+ def create_migration_file
107
+ if @model_existed_before_run
108
+ say_status :skip, 'migration (model already exists; add a new migration for schema changes)', :yellow
109
+ return
110
+ end
111
+ super
112
+ end
113
+
114
+ # --- API surface ---
115
+
116
+ def create_store_controller
117
+ return unless options[:store]
118
+
119
+ template 'store_controller.rb.tt', store_controller_path
120
+ end
121
+
122
+ def create_admin_controller
123
+ return unless options[:admin]
124
+
125
+ template 'admin_controller.rb.tt', admin_controller_path
126
+ end
127
+
128
+ def create_store_serializer
129
+ return unless options[:store]
130
+
131
+ template 'store_serializer.rb.tt', store_serializer_path
132
+
133
+ # --store-name aliases the store-facing class under a different name
134
+ # (e.g. Brand → Discount) while keeping the model/table internal.
135
+ if store_external_name != bare_class_name
136
+ template 'store_aliased_serializer.rb.tt',
137
+ "app/serializers/spree/api/v3/#{store_external_name.underscore}_serializer.rb"
138
+ end
139
+ end
140
+
141
+ def create_admin_serializer
142
+ return unless options[:admin]
143
+
144
+ template 'admin_serializer.rb.tt', admin_serializer_path
145
+ end
146
+
147
+ def create_factory
148
+ # spec/factories/ is the FactoryBot default scan path that a freshly-
149
+ # generated `rspec:install` + `factory_bot_rails` setup already picks
150
+ # up via `FactoryBot.find_definitions`. Spree's own factories live
151
+ # under lib/spree/testing_support/factories/ because that path is
152
+ # exported by gems; downstream apps don't have that loader by default.
153
+ template 'factory.rb.tt', "spec/factories/spree/#{singular_name}_factory.rb"
154
+ end
155
+
156
+ def create_controller_specs
157
+ return if options[:skip_specs]
158
+
159
+ if options[:store]
160
+ template 'store_controller_spec.rb.tt',
161
+ "spec/controllers/spree/api/v3/store/#{plural_name}_controller_spec.rb"
162
+ end
163
+ if options[:admin]
164
+ template 'admin_controller_spec.rb.tt',
165
+ "spec/controllers/spree/api/v3/admin/#{plural_name}_controller_spec.rb"
166
+ end
167
+ end
168
+
169
+ def inject_routes
170
+ return if options[:skip_routes]
171
+
172
+ routes_file = api_routes_path
173
+
174
+ unless File.exist?(routes_file) && File.writable?(routes_file)
175
+ say_status :skip, "routes.rb at #{routes_file} (not writable — only edge installs can modify gem source)", :yellow
176
+ return
177
+ end
178
+
179
+ inject_route_for(:store, store_route_line) if options[:store]
180
+ inject_route_for(:admin, admin_route_line) if options[:admin]
181
+ end
182
+
183
+ def print_summary
184
+ say ''
185
+ say "✓ Generated Spree::#{bare_class_name} API resource", :green
186
+ say ''
187
+ say " Prefixed ID: #{id_prefix}_xxxxxxxxxx (edit `has_prefix_id` in the model to change)"
188
+ if store_external_name != bare_class_name
189
+ say " Store API: /api/v3/store/#{store_external_plural} (aliased from #{bare_class_name})"
190
+ elsif options[:store]
191
+ say " Store API: /api/v3/store/#{plural_name} (#{writable? ? 'full CRUD' : 'read-only'})"
192
+ end
193
+ say " Admin API: /api/v3/admin/#{plural_name} (full CRUD)" if options[:admin]
194
+ say ''
195
+ say ' Next steps:', :yellow
196
+ say ' 1. Review the generated model — add validations, scopes, callbacks'
197
+ say ' 2. Apply the migration: pnpm exec spree migrate'
198
+ say ' 3. Set up authorization (CanCanCan ability) for the resource'
199
+ say ' 4. Decide whether this resource is store-scoped (add `has_many` on Store)'
200
+ if options[:store] || options[:admin]
201
+ say ' 5. Run the specs: pnpm exec spree exec bundle exec rspec spec/controllers/spree/api/v3/'
202
+ end
203
+ say ''
204
+ end
205
+
206
+ # --- Helpers exposed to templates and other actions ---
207
+
208
+ no_tasks do
209
+ def writable?
210
+ options[:writable]
211
+ end
212
+
213
+ # NamedBase's class_name includes the namespace prefix (e.g. Spree::Brand
214
+ # when class_path is forced to ["spree"]). Templates that nest under
215
+ # `module Spree` need the bare name to avoid Spree::Spree::Brand.
216
+ def bare_class_name
217
+ class_name.demodulize
218
+ end
219
+
220
+ # The external name the Store API surfaces this as. Defaults to the
221
+ # canonical class name; --store-name overrides (e.g. Brand → Discount).
222
+ def store_external_name
223
+ options[:store_name] || bare_class_name
224
+ end
225
+
226
+ def store_external_plural
227
+ store_external_name.tableize
228
+ end
229
+
230
+ # Used in templates that refer to the resource by singular/plural
231
+ # snake_case identifiers (factory names, file names, route names).
232
+ def singular_name
233
+ file_name
234
+ end
235
+
236
+ def plural_name
237
+ file_name.pluralize
238
+ end
239
+
240
+ # Attributes the controller permits on write. By default, every column
241
+ # except references (FKs come through nested), attachments, rich text.
242
+ def permitted_attribute_names
243
+ attributes
244
+ .reject { |a| a.reference? || a.attachment? || a.attachments? || a.rich_text? || a.token? || a.password_digest? }
245
+ .map(&:name)
246
+ end
247
+ end
248
+
249
+ private
250
+
251
+ # Where the model file lands. We use parent's class_path + file_name so
252
+ # this stays in sync with whatever Spree::ModelGenerator decides.
253
+ def model_file_destination
254
+ File.join('app/models', class_path, "#{file_name}.rb")
255
+ end
256
+
257
+ def store_controller_path
258
+ "app/controllers/spree/api/v3/store/#{plural_name}_controller.rb"
259
+ end
260
+
261
+ def admin_controller_path
262
+ "app/controllers/spree/api/v3/admin/#{plural_name}_controller.rb"
263
+ end
264
+
265
+ def store_serializer_path
266
+ "app/serializers/spree/api/v3/#{singular_name}_serializer.rb"
267
+ end
268
+
269
+ def admin_serializer_path
270
+ "app/serializers/spree/api/v3/admin/#{singular_name}_serializer.rb"
271
+ end
272
+
273
+ # Absolute path to spree_api's routes.rb. On edge installs (SPREE_PATH set)
274
+ # this resolves to the monorepo source; on a published-gem install it
275
+ # resolves to the gem cache directory (typically read-only).
276
+ def api_routes_path
277
+ File.join(Gem.loaded_specs['spree_api'].full_gem_path, 'config/routes.rb')
278
+ end
279
+
280
+ # The routes line we inject into the Store namespace.
281
+ # --writable expands to full CRUD; default is read-only (index/show).
282
+ def store_route_line
283
+ if store_external_name != bare_class_name
284
+ # --store-name Discount: alias under a different external name,
285
+ # but route to the canonical controller (brands).
286
+ if writable?
287
+ " resources :#{store_external_plural}, controller: '#{plural_name}'"
288
+ else
289
+ " resources :#{store_external_plural}, controller: '#{plural_name}', only: [:index, :show]"
290
+ end
291
+ elsif writable?
292
+ " resources :#{plural_name}"
293
+ else
294
+ " resources :#{plural_name}, only: [:index, :show]"
295
+ end
296
+ end
297
+
298
+ # Admin always gets full CRUD.
299
+ def admin_route_line
300
+ " resources :#{plural_name}"
301
+ end
302
+
303
+ # The 8-space-indented sentinel markers.
304
+ BEGIN_MARKER = ' # BEGIN spree:api_resource managed routes'.freeze
305
+ END_MARKER = ' # END spree:api_resource managed routes'.freeze
306
+
307
+ # Idempotent injection. First run: insert sentinels + the route line at
308
+ # the top of the namespace. Subsequent runs: find the existing sentinels
309
+ # and insert the route line between them only if not already present.
310
+ def inject_route_for(namespace, route_line)
311
+ file = api_routes_path
312
+ content = File.read(file)
313
+
314
+ sentinel_pattern = sentinel_pattern_for(namespace)
315
+
316
+ if content =~ sentinel_pattern
317
+ # Sentinels exist for this namespace — find the block, check if
318
+ # `resources :<plural>` is already there.
319
+ block = Regexp.last_match(0)
320
+ if block.include?(route_line.strip)
321
+ say_status :identical, "routes.rb (#{namespace}: #{route_line.strip})", :blue
322
+ return
323
+ end
324
+
325
+ # Insert just above the END marker, preserving the existing block.
326
+ new_block = block.sub(END_MARKER, "#{route_line}\n#{END_MARKER}")
327
+ File.write(file, content.sub(block, new_block))
328
+ say_status :inject, "routes.rb (#{namespace}: #{route_line.strip})", :green
329
+ else
330
+ # First run for this namespace — inject the full block right after
331
+ # the namespace's opening line.
332
+ opening = " namespace :#{namespace} do\n"
333
+ new_block = "#{BEGIN_MARKER}\n#{route_line}\n#{END_MARKER}\n\n"
334
+ updated = content.sub(opening, opening + new_block)
335
+ if updated == content
336
+ # The namespace anchor wasn't found — likely a non-default
337
+ # routes.rb. Warn rather than silently claim success.
338
+ say_status :skip, "routes.rb (#{namespace}: namespace block not found — add manually)", :yellow
339
+ return
340
+ end
341
+ File.write(file, updated)
342
+ say_status :inject, "routes.rb (#{namespace}: sentinels + #{route_line.strip})", :green
343
+ end
344
+ end
345
+
346
+ # Match the BEGIN…END block scoped to a specific namespace. Anchors on
347
+ # the namespace's opening line so we don't accidentally match the Admin
348
+ # block when looking at Store and vice versa.
349
+ def sentinel_pattern_for(namespace)
350
+ /#{Regexp.escape("namespace :#{namespace} do")}.*?#{Regexp.escape(BEGIN_MARKER)}.*?#{Regexp.escape(END_MARKER)}/m
351
+ end
352
+ end
353
+ end
@@ -0,0 +1,23 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ class <%= plural_name.camelize %>Controller < ResourceController
6
+ protected
7
+
8
+ def model_class
9
+ Spree::<%= class_name.demodulize %>
10
+ end
11
+
12
+ def serializer_class
13
+ Spree::Api::V3::Admin::<%= bare_class_name %>Serializer
14
+ end
15
+
16
+ def permitted_params
17
+ params.permit(<%= permitted_attribute_names.map { |a| ":#{a}" }.join(', ') %>)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,59 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe Spree::Api::V3::Admin::<%= plural_name.camelize %>Controller, type: :controller do
4
+ render_views
5
+
6
+ include_context 'API v3 Admin authenticated'
7
+
8
+ let!(:<%= singular_name %>) { create(:<%= singular_name %>) }
9
+
10
+ before { request.headers.merge!(headers) }
11
+
12
+ describe 'GET #index' do
13
+ subject { get :index, as: :json }
14
+
15
+ it 'returns <%= plural_name %>' do
16
+ subject
17
+ expect(response).to have_http_status(:ok)
18
+ expect(json_response['data']).to be_an(Array)
19
+ expect(json_response['data'].map { |r| r['id'] }).to include(<%= singular_name %>.prefixed_id)
20
+ end
21
+ end
22
+
23
+ describe 'GET #show' do
24
+ subject { get :show, params: { id: <%= singular_name %>.prefixed_id }, as: :json }
25
+
26
+ it 'returns the <%= singular_name %>' do
27
+ subject
28
+ expect(response).to have_http_status(:ok)
29
+ expect(json_response['id']).to eq(<%= singular_name %>.prefixed_id)
30
+ end
31
+ end
32
+
33
+ describe 'POST #create' do
34
+ let(:create_params) { attributes_for(:<%= singular_name %>) }
35
+
36
+ it 'creates a <%= singular_name %>' do
37
+ expect { post :create, params: create_params, as: :json }.to change(Spree::<%= bare_class_name %>, :count).by(1)
38
+ expect(response).to have_http_status(:created)
39
+ end
40
+ end
41
+
42
+ describe 'PATCH #update' do
43
+ let(:update_params) { { id: <%= singular_name %>.prefixed_id }.merge(attributes_for(:<%= singular_name %>)) }
44
+
45
+ it 'updates the <%= singular_name %>' do
46
+ patch :update, params: update_params, as: :json
47
+ expect(response).to have_http_status(:ok)
48
+ end
49
+ end
50
+
51
+ describe 'DELETE #destroy' do
52
+ subject { delete :destroy, params: { id: <%= singular_name %>.prefixed_id }, as: :json }
53
+
54
+ it 'deletes the <%= singular_name %>' do
55
+ expect { subject }.to change(Spree::<%= bare_class_name %>, :count).by(-1)
56
+ expect(response).to have_http_status(:no_content)
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,11 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Admin
5
+ class <%= bare_class_name %>Serializer < V3::<%= bare_class_name %>Serializer
6
+ attributes :created_at, :updated_at<%= ', :deleted_at' if paranoid? %>
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,26 @@
1
+ FactoryBot.define do
2
+ factory :<%= singular_name %>, class: Spree::<%= bare_class_name %> do
3
+ <% attributes.reject(&:reference?).each do |attr| -%>
4
+ <% case attr.type
5
+ when :boolean -%>
6
+ <%= attr.name %> { true }
7
+ <% when :integer, :decimal, :float -%>
8
+ <%= attr.name %> { 1 }
9
+ <% when :date -%>
10
+ <%= attr.name %> { Date.current }
11
+ <% when :datetime -%>
12
+ <%= attr.name %> { Time.current }
13
+ <% else -%>
14
+ sequence(:<%= attr.name %>) { |n| "<%= bare_class_name %> #{n}" }
15
+ <% end -%>
16
+ <% end -%>
17
+ <% attributes.select(&:reference?).each do |attr| -%>
18
+ <% if attr.polymorphic? -%>
19
+ # <%= attr.name %> is polymorphic — point it at a concrete factory before using:
20
+ # association :<%= attr.name %>, factory: :product
21
+ <% else -%>
22
+ association :<%= attr.name %>
23
+ <% end -%>
24
+ <% end -%>
25
+ end
26
+ end
@@ -0,0 +1,12 @@
1
+ # --store-name=<%= store_external_name %>: this serializer aliases <%= bare_class_name %>Serializer
2
+ # so the Store API surfaces the resource under a different external name
3
+ # (e.g. internal Brand → public Discount). The model and table stay as
4
+ # <%= bare_class_name %> / <%= table_name %>.
5
+ module Spree
6
+ module Api
7
+ module V3
8
+ class <%= store_external_name %>Serializer < <%= bare_class_name %>Serializer
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,31 @@
1
+ module Spree
2
+ module Api
3
+ module V3
4
+ module Store
5
+ class <%= plural_name.camelize %>Controller < ResourceController
6
+ protected
7
+
8
+ def model_class
9
+ Spree::<%= class_name.demodulize %>
10
+ end
11
+
12
+ <% if store_external_name != bare_class_name -%>
13
+ def serializer_class
14
+ Spree::Api::V3::<%= store_external_name %>Serializer
15
+ end
16
+ <% else -%>
17
+ def serializer_class
18
+ Spree::Api::V3::<%= bare_class_name %>Serializer
19
+ end
20
+ <% end -%>
21
+ <% if writable? -%>
22
+
23
+ def permitted_params
24
+ params.permit(<%= permitted_attribute_names.map { |a| ":#{a}" }.join(', ') %>)
25
+ end
26
+ <% end -%>
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,61 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe Spree::Api::V3::Store::<%= plural_name.camelize %>Controller, type: :controller do
4
+ render_views
5
+
6
+ include_context 'API v3 Store'
7
+
8
+ let!(:<%= singular_name %>) { create(:<%= singular_name %>) }
9
+
10
+ before { request.headers.merge!(headers) }
11
+
12
+ describe 'GET #index' do
13
+ subject { get :index, as: :json }
14
+
15
+ it 'returns <%= plural_name %>' do
16
+ subject
17
+ expect(response).to have_http_status(:ok)
18
+ expect(json_response['data']).to be_an(Array)
19
+ expect(json_response['data'].map { |r| r['id'] }).to include(<%= singular_name %>.prefixed_id)
20
+ end
21
+ end
22
+
23
+ describe 'GET #show' do
24
+ subject { get :show, params: { id: <%= singular_name %>.prefixed_id }, as: :json }
25
+
26
+ it 'returns the <%= singular_name %>' do
27
+ subject
28
+ expect(response).to have_http_status(:ok)
29
+ expect(json_response['id']).to eq(<%= singular_name %>.prefixed_id)
30
+ end
31
+ end
32
+ <% if writable? -%>
33
+
34
+ describe 'POST #create' do
35
+ let(:create_params) { attributes_for(:<%= singular_name %>) }
36
+
37
+ it 'creates a <%= singular_name %>' do
38
+ expect { post :create, params: create_params, as: :json }.to change(Spree::<%= bare_class_name %>, :count).by(1)
39
+ expect(response).to have_http_status(:created)
40
+ end
41
+ end
42
+
43
+ describe 'PATCH #update' do
44
+ let(:update_params) { { id: <%= singular_name %>.prefixed_id }.merge(attributes_for(:<%= singular_name %>)) }
45
+
46
+ it 'updates the <%= singular_name %>' do
47
+ patch :update, params: update_params, as: :json
48
+ expect(response).to have_http_status(:ok)
49
+ end
50
+ end
51
+
52
+ describe 'DELETE #destroy' do
53
+ subject { delete :destroy, params: { id: <%= singular_name %>.prefixed_id }, as: :json }
54
+
55
+ it 'deletes the <%= singular_name %>' do
56
+ expect { subject }.to change(Spree::<%= bare_class_name %>, :count).by(-1)
57
+ expect(response).to have_http_status(:no_content)
58
+ end
59
+ end
60
+ <% end -%>
61
+ end
@@ -0,0 +1,17 @@
1
+ <%# Decimal columns serialize as strings on the wire (Alba's oj_rails backend
2
+ casts BigDecimal via as_json), so they must be typed :string — only
3
+ integer/float are real JSON numbers. -%>
4
+ <% typelizable = attributes.reject(&:reference?).select { |a| %i[boolean integer decimal float].include?(a.type) } -%>
5
+ module Spree
6
+ module Api
7
+ module V3
8
+ class <%= bare_class_name %>Serializer < BaseSerializer
9
+ <% if typelizable.any? -%>
10
+ typelize <%= typelizable.map { |a| type = a.type == :boolean ? 'boolean' : (a.type == :decimal ? 'string' : 'number'); "#{a.name}: :#{type}" }.join(', ') %>
11
+
12
+ <% end -%>
13
+ attributes <%= permitted_attribute_names.map { |a| ":#{a}" }.join(', ') %>
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,66 @@
1
+ module Spree
2
+ # spree:controller_decorator — generate a decorator file for an existing
3
+ # Spree controller. Mirrors the model_decorator generator but handles
4
+ # arbitrary namespace depth (Spree::ProductsController,
5
+ # Spree::Admin::ProductsController, Spree::Api::V3::Store::ProductsController).
6
+ #
7
+ # @example
8
+ # bin/rails g spree:controller_decorator Spree::ProductsController
9
+ # => app/controllers/spree/products_controller_decorator.rb
10
+ #
11
+ # bin/rails g spree:controller_decorator Spree::Admin::ProductsController
12
+ # => app/controllers/spree/admin/products_controller_decorator.rb
13
+ class ControllerDecoratorGenerator < Rails::Generators::NamedBase
14
+ desc 'Creates a controller decorator for a Spree controller'
15
+
16
+ argument :name, type: :string, required: true,
17
+ banner: 'Spree::ControllerName | Spree::Namespace::ControllerName'
18
+
19
+ def self.source_paths
20
+ paths = superclass.source_paths
21
+ paths << File.expand_path('templates', __dir__)
22
+ paths.flatten
23
+ end
24
+
25
+ def create_controller_decorator_file
26
+ template 'controller_decorator.rb.tt',
27
+ "app/controllers/#{file_path}_decorator.rb"
28
+ end
29
+
30
+ private
31
+
32
+ # Strip a leading `Spree::` and split the remainder on `::`.
33
+ # `"Spree::Admin::ProductsController"` => `["Admin", "ProductsController"]`
34
+ def name_parts
35
+ @name_parts ||= name.sub(/\ASpree::/, '').split('::').reject(&:empty?)
36
+ end
37
+
38
+ # The unqualified controller name — the last segment.
39
+ def controller_name
40
+ name_parts.last
41
+ end
42
+
43
+ # The namespace segments above the controller, joined as a constant
44
+ # chain. Empty string when the controller sits directly under Spree.
45
+ def namespace_chain
46
+ name_parts[0..-2].join('::')
47
+ end
48
+
49
+ # The decorator module name.
50
+ def decorator_name
51
+ "#{controller_name}Decorator"
52
+ end
53
+
54
+ # Path under app/controllers/, including the `spree/` root.
55
+ def file_path
56
+ ['spree', *name_parts.map(&:underscore)].join('/')
57
+ end
58
+
59
+ # Fully-qualified `Spree::Foo::Bar.prepend Spree::Foo::BarDecorator`.
60
+ def prepend_invocation
61
+ target = "Spree::#{name_parts.join('::')}"
62
+ module_chain = ['Spree', *name_parts[0..-2], decorator_name].join('::')
63
+ "#{target}.prepend #{module_chain}"
64
+ end
65
+ end
66
+ end