habarico 7.2.0.pre.beta.5

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 (221) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +82 -0
  4. data/Rakefile +15 -0
  5. data/VERSION +1 -0
  6. data/app/controllers/habarico/action_schema_controller.rb +41 -0
  7. data/app/controllers/habarico/application_controller.rb +15 -0
  8. data/app/controllers/habarico/contrib/preferences/user_preferences_controller.rb +105 -0
  9. data/app/controllers/habarico/global_schema_controller.rb +11 -0
  10. data/app/controllers/habarico/local_schema_controller.rb +35 -0
  11. data/app/helpers/habarico/application_helper.rb +4 -0
  12. data/app/models/habarico/application_record.rb +5 -0
  13. data/app/views/layouts/habarico/application.html.erb +17 -0
  14. data/config/locales/cs.yml +39 -0
  15. data/config/locales/de.yml +39 -0
  16. data/config/locales/en.yml +39 -0
  17. data/config/locales/es.yml +39 -0
  18. data/config/locales/fr.yml +39 -0
  19. data/config/locales/it.yml +39 -0
  20. data/config/locales/ja.yml +39 -0
  21. data/config/locales/nl.yml +39 -0
  22. data/config/locales/pl.yml +39 -0
  23. data/config/locales/pt.yml +39 -0
  24. data/config/locales/ro.yml +39 -0
  25. data/config/locales/sk.yml +39 -0
  26. data/config/locales/uk.yml +39 -0
  27. data/config/routes.rb +1 -0
  28. data/lib/generators/habarico/preferences/install_generator.rb +27 -0
  29. data/lib/generators/habarico/preferences/templates/create_habarico_user_preferences.rb.tt +15 -0
  30. data/lib/habarico/active_model/references.rb +68 -0
  31. data/lib/habarico/authenticatable.rb +31 -0
  32. data/lib/habarico/config.rb +71 -0
  33. data/lib/habarico/context.rb +126 -0
  34. data/lib/habarico/contrib/preferences/base_preferences.rb +40 -0
  35. data/lib/habarico/contrib/preferences/config.rb +31 -0
  36. data/lib/habarico/contrib/preferences/errors.rb +79 -0
  37. data/lib/habarico/contrib/preferences/locales/cs.yml +8 -0
  38. data/lib/habarico/contrib/preferences/locales/de.yml +8 -0
  39. data/lib/habarico/contrib/preferences/locales/en.yml +8 -0
  40. data/lib/habarico/contrib/preferences/locales/es.yml +8 -0
  41. data/lib/habarico/contrib/preferences/locales/fr.yml +8 -0
  42. data/lib/habarico/contrib/preferences/locales/it.yml +8 -0
  43. data/lib/habarico/contrib/preferences/locales/ja.yml +8 -0
  44. data/lib/habarico/contrib/preferences/locales/nl.yml +8 -0
  45. data/lib/habarico/contrib/preferences/locales/pl.yml +8 -0
  46. data/lib/habarico/contrib/preferences/locales/pt.yml +8 -0
  47. data/lib/habarico/contrib/preferences/locales/ro.yml +8 -0
  48. data/lib/habarico/contrib/preferences/locales/sk.yml +8 -0
  49. data/lib/habarico/contrib/preferences/locales/uk.yml +8 -0
  50. data/lib/habarico/contrib/preferences/registry.rb +293 -0
  51. data/lib/habarico/contrib/preferences/routes.rb +17 -0
  52. data/lib/habarico/contrib/preferences/schemas.rb +129 -0
  53. data/lib/habarico/contrib/preferences/serializer.rb +19 -0
  54. data/lib/habarico/contrib/preferences/user_preference.rb +16 -0
  55. data/lib/habarico/contrib/preferences.rb +32 -0
  56. data/lib/habarico/controller.rb +584 -0
  57. data/lib/habarico/definition/action_reference.rb +18 -0
  58. data/lib/habarico/definition/actions/action.rb +14 -0
  59. data/lib/habarico/definition/actions/create_action.rb +18 -0
  60. data/lib/habarico/definition/actions/delete_action.rb +20 -0
  61. data/lib/habarico/definition/actions/edit_action.rb +18 -0
  62. data/lib/habarico/definition/actions/link_action.rb +11 -0
  63. data/lib/habarico/definition/actions/resource_action.rb +12 -0
  64. data/lib/habarico/definition/actions/save_action.rb +14 -0
  65. data/lib/habarico/definition/actions/show_action.rb +18 -0
  66. data/lib/habarico/definition/actions/upload_images_action.rb +11 -0
  67. data/lib/habarico/definition/actions/view_action.rb +11 -0
  68. data/lib/habarico/definition/boxes/array_box.rb +10 -0
  69. data/lib/habarico/definition/boxes/box.rb +32 -0
  70. data/lib/habarico/definition/boxes/gallery_box.rb +10 -0
  71. data/lib/habarico/definition/boxes/grid_box.rb +10 -0
  72. data/lib/habarico/definition/boxes/grid_item_box.rb +10 -0
  73. data/lib/habarico/definition/boxes/list_box.rb +10 -0
  74. data/lib/habarico/definition/boxes/object_box.rb +10 -0
  75. data/lib/habarico/definition/boxes/tab_box.rb +10 -0
  76. data/lib/habarico/definition/boxes/tab_item_box.rb +10 -0
  77. data/lib/habarico/definition/boxes/wizard_box.rb +10 -0
  78. data/lib/habarico/definition/concerns/definition_model.rb +38 -0
  79. data/lib/habarico/definition/definition.rb +206 -0
  80. data/lib/habarico/definition/field_reference.rb +9 -0
  81. data/lib/habarico/definition/filter_reference.rb +7 -0
  82. data/lib/habarico/definition/menu/link_menu_item.rb +12 -0
  83. data/lib/habarico/definition/menu/menu_item.rb +9 -0
  84. data/lib/habarico/definition/menu/text_menu_item.rb +12 -0
  85. data/lib/habarico/definition/menu/view_menu_item.rb +13 -0
  86. data/lib/habarico/definition/response.rb +13 -0
  87. data/lib/habarico/definition/views/action_view.rb +11 -0
  88. data/lib/habarico/definition/views/create_view.rb +15 -0
  89. data/lib/habarico/definition/views/edit_view.rb +15 -0
  90. data/lib/habarico/definition/views/list_view.rb +17 -0
  91. data/lib/habarico/definition/views/show_view.rb +15 -0
  92. data/lib/habarico/definition/views/view.rb +10 -0
  93. data/lib/habarico/engine.rb +6 -0
  94. data/lib/habarico/file_validations.rb +119 -0
  95. data/lib/habarico/generators/abstract_generator.rb +21 -0
  96. data/lib/habarico/generators/action/action_schema_generator.rb +111 -0
  97. data/lib/habarico/generators/action/confirm_dialog_properties_generator.rb +30 -0
  98. data/lib/habarico/generators/action/global_resource_action_schema_generator.rb +46 -0
  99. data/lib/habarico/generators/action/link_action_parameters_generator.rb +31 -0
  100. data/lib/habarico/generators/action/resource_action_endpoint_parameters_generator.rb +70 -0
  101. data/lib/habarico/generators/action/resource_action_parameters_generator.rb +40 -0
  102. data/lib/habarico/generators/action/resource_action_schema_generator.rb +51 -0
  103. data/lib/habarico/generators/action/save_action_parameters_generator.rb +26 -0
  104. data/lib/habarico/generators/action/upload_images_action_parameters_generator.rb +41 -0
  105. data/lib/habarico/generators/action/view_action_endpoint_parameters_generator.rb +37 -0
  106. data/lib/habarico/generators/action/view_action_parameters_generator.rb +56 -0
  107. data/lib/habarico/generators/endpoint/dynamic_render_options_generator.rb +26 -0
  108. data/lib/habarico/generators/endpoint/endpoints_schema_generator.rb +61 -0
  109. data/lib/habarico/generators/endpoint/enum_type_generator.rb +45 -0
  110. data/lib/habarico/generators/endpoint/field_options_generator.rb +179 -0
  111. data/lib/habarico/generators/endpoint/field_schema_generator.rb +381 -0
  112. data/lib/habarico/generators/endpoint/fields_schema_generator.rb +35 -0
  113. data/lib/habarico/generators/endpoint/filter_schema_generator.rb +201 -0
  114. data/lib/habarico/generators/endpoint/filters_schema_generator.rb +25 -0
  115. data/lib/habarico/generators/endpoint/method_schema_generator.rb +34 -0
  116. data/lib/habarico/generators/endpoint/number_field_options_generator.rb +45 -0
  117. data/lib/habarico/generators/endpoint/phone_field_options_generator.rb +29 -0
  118. data/lib/habarico/generators/endpoint/relation_field_options_generator.rb +90 -0
  119. data/lib/habarico/generators/endpoint/search_filter_schema_generator.rb +44 -0
  120. data/lib/habarico/generators/menu/menu_group_schema_generator.rb +63 -0
  121. data/lib/habarico/generators/menu/menu_link_parameters_generator.rb +31 -0
  122. data/lib/habarico/generators/menu/menu_schema_generator.rb +105 -0
  123. data/lib/habarico/generators/menu/menu_view_endpoint_parameters_generator.rb +44 -0
  124. data/lib/habarico/generators/menu/menu_view_parameters_generator.rb +50 -0
  125. data/lib/habarico/generators/resource/action_resource_schema_generator.rb +84 -0
  126. data/lib/habarico/generators/resource/global_resource_schema_generator.rb +70 -0
  127. data/lib/habarico/generators/resource/local_resource_schema_generator.rb +111 -0
  128. data/lib/habarico/generators/resource/object_resource_schema_generator.rb +49 -0
  129. data/lib/habarico/generators/resource/resource_schema_generator.rb +31 -0
  130. data/lib/habarico/generators/view/action_descriptor_generator.rb +88 -0
  131. data/lib/habarico/generators/view/array_box_parameters_generator.rb +34 -0
  132. data/lib/habarico/generators/view/box_options_generator.rb +23 -0
  133. data/lib/habarico/generators/view/box_schema_generator.rb +194 -0
  134. data/lib/habarico/generators/view/detail_view_parameters_generator.rb +57 -0
  135. data/lib/habarico/generators/view/field_descriptor_generator.rb +107 -0
  136. data/lib/habarico/generators/view/filter_descriptor_generator.rb +75 -0
  137. data/lib/habarico/generators/view/gallery_box_parameters_generator.rb +71 -0
  138. data/lib/habarico/generators/view/global_view_schema_generator.rb +59 -0
  139. data/lib/habarico/generators/view/grid_item_box_parameters_generator.rb +44 -0
  140. data/lib/habarico/generators/view/list_box_parameters_generator.rb +124 -0
  141. data/lib/habarico/generators/view/list_view_parameters_generator.rb +120 -0
  142. data/lib/habarico/generators/view/object_box_parameters_generator.rb +26 -0
  143. data/lib/habarico/generators/view/sort_descriptor_generator.rb +30 -0
  144. data/lib/habarico/generators/view/tab_box_parameters_generator.rb +26 -0
  145. data/lib/habarico/generators/view/view_endpoint_parameters_generator.rb +71 -0
  146. data/lib/habarico/generators/view/view_schema_generator.rb +87 -0
  147. data/lib/habarico/generators/view/wizard_box_parameters_generator.rb +32 -0
  148. data/lib/habarico/helpers/action_helper.rb +9 -0
  149. data/lib/habarico/helpers/attributes_helper.rb +67 -0
  150. data/lib/habarico/helpers/box_helper.rb +19 -0
  151. data/lib/habarico/helpers/callable_helper.rb +15 -0
  152. data/lib/habarico/helpers/filter_params_helper.rb +49 -0
  153. data/lib/habarico/helpers/order_by_helper.rb +56 -0
  154. data/lib/habarico/helpers/reflection_helper.rb +61 -0
  155. data/lib/habarico/helpers/sorting_helper.rb +136 -0
  156. data/lib/habarico/schema/action/action_schema.rb +46 -0
  157. data/lib/habarico/schema/action/confirm_dialog_properties.rb +13 -0
  158. data/lib/habarico/schema/action/global_resource_action_schema.rb +29 -0
  159. data/lib/habarico/schema/action/link_action_parameters.rb +15 -0
  160. data/lib/habarico/schema/action/resource_action_endpoint_parameters.rb +24 -0
  161. data/lib/habarico/schema/action/resource_action_parameters.rb +17 -0
  162. data/lib/habarico/schema/action/resource_action_schema.rb +14 -0
  163. data/lib/habarico/schema/action/save_action_parameters.rb +10 -0
  164. data/lib/habarico/schema/action/upload_images_action_parameters.rb +23 -0
  165. data/lib/habarico/schema/action/view_action_endpoint_parameters.rb +16 -0
  166. data/lib/habarico/schema/action/view_action_parameters.rb +27 -0
  167. data/lib/habarico/schema/endpoint/ai_field_options.rb +10 -0
  168. data/lib/habarico/schema/endpoint/box_options.rb +10 -0
  169. data/lib/habarico/schema/endpoint/choices_field_options.rb +10 -0
  170. data/lib/habarico/schema/endpoint/dynamic_render_options.rb +14 -0
  171. data/lib/habarico/schema/endpoint/endpoints_schema.rb +22 -0
  172. data/lib/habarico/schema/endpoint/enum_type.rb +21 -0
  173. data/lib/habarico/schema/endpoint/field_options.rb +56 -0
  174. data/lib/habarico/schema/endpoint/field_schema.rb +65 -0
  175. data/lib/habarico/schema/endpoint/filter_schema.rb +44 -0
  176. data/lib/habarico/schema/endpoint/image_field_options.rb +13 -0
  177. data/lib/habarico/schema/endpoint/key_value.rb +13 -0
  178. data/lib/habarico/schema/endpoint/method_schema.rb +13 -0
  179. data/lib/habarico/schema/endpoint/number_field_options.rb +24 -0
  180. data/lib/habarico/schema/endpoint/phone_field_options.rb +13 -0
  181. data/lib/habarico/schema/endpoint/relation_field_options.rb +39 -0
  182. data/lib/habarico/schema/extensible_enum.rb +20 -0
  183. data/lib/habarico/schema/menu/action_view_choice.rb +13 -0
  184. data/lib/habarico/schema/menu/menu_group_schema.rb +26 -0
  185. data/lib/habarico/schema/menu/menu_schema.rb +36 -0
  186. data/lib/habarico/schema/resource/global_resource_schema.rb +14 -0
  187. data/lib/habarico/schema/resource/local_resource_schema.rb +14 -0
  188. data/lib/habarico/schema/resource/object_resource_schema.rb +8 -0
  189. data/lib/habarico/schema/resource/resource_schema.rb +16 -0
  190. data/lib/habarico/schema/types.rb +102 -0
  191. data/lib/habarico/schema/view/action_descriptor.rb +30 -0
  192. data/lib/habarico/schema/view/array_box_parameters.rb +13 -0
  193. data/lib/habarico/schema/view/box_schema.rb +67 -0
  194. data/lib/habarico/schema/view/detail_view_parameters.rb +16 -0
  195. data/lib/habarico/schema/view/field_descriptor.rb +50 -0
  196. data/lib/habarico/schema/view/field_wrapper_properties.rb +13 -0
  197. data/lib/habarico/schema/view/filter_descriptor.rb +24 -0
  198. data/lib/habarico/schema/view/gallery_box_parameters.rb +31 -0
  199. data/lib/habarico/schema/view/global_view_schema.rb +25 -0
  200. data/lib/habarico/schema/view/grid_item_box_parameters.rb +13 -0
  201. data/lib/habarico/schema/view/list_box_parameters.rb +44 -0
  202. data/lib/habarico/schema/view/list_view_parameters.rb +31 -0
  203. data/lib/habarico/schema/view/object_box_parameters.rb +10 -0
  204. data/lib/habarico/schema/view/sort_descriptor.rb +15 -0
  205. data/lib/habarico/schema/view/sortable_parameters.rb +13 -0
  206. data/lib/habarico/schema/view/tab_box_parameters.rb +10 -0
  207. data/lib/habarico/schema/view/view_action_descriptor_parameters.rb +21 -0
  208. data/lib/habarico/schema/view/view_endpoint_parameters.rb +22 -0
  209. data/lib/habarico/schema/view/view_schema.rb +33 -0
  210. data/lib/habarico/schema/view/wizard_box_parameters.rb +13 -0
  211. data/lib/habarico/search/field_resolver.rb +138 -0
  212. data/lib/habarico/search/search_matcher.rb +152 -0
  213. data/lib/habarico/serializers/meta_serializer.rb +42 -0
  214. data/lib/habarico/serializers/record_serializer.rb +33 -0
  215. data/lib/habarico/serializers/serializer.rb +61 -0
  216. data/lib/habarico/services/resource_service.rb +69 -0
  217. data/lib/habarico/services/routing_service.rb +160 -0
  218. data/lib/habarico/services/schema_service.rb +72 -0
  219. data/lib/habarico/version.rb +5 -0
  220. data/lib/habarico.rb +262 -0
  221. metadata +324 -0
@@ -0,0 +1,16 @@
1
+ require "active_record"
2
+
3
+ module Habarico
4
+ module Contrib
5
+ module Preferences
6
+ # Per-user preference row. Keyed on the scalar `user_id` (the auth token subject
7
+ # `current_user.id`), NOT a DB foreign key: in the token-based, multi-service
8
+ # setup the user is often not a local ActiveRecord row, so there is no local table
9
+ # to reference. Hosts that do have a local users table can add a FK in their own
10
+ # (generator-copied) migration.
11
+ class UserPreference < ::ActiveRecord::Base
12
+ self.table_name = "habarico_user_preferences"
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,32 @@
1
+ # Optional Habarico module providing a per-user, JSON-Schema-validated preference store
2
+ # (theme, locale, dashboard layout, saved queries, column selection, ...). It is NOT loaded
3
+ # by `require "habarico"`; the host app opts in with `require "habarico/contrib/preferences"`
4
+ # and adds the `json_schemer` gem to its Gemfile.
5
+ begin
6
+ require "json_schemer"
7
+ rescue LoadError => e
8
+ raise LoadError, "habarico/contrib/preferences requires the `json_schemer` gem. " \
9
+ "Add it to your Gemfile.\n#{e.message}"
10
+ end
11
+
12
+ require "habarico"
13
+
14
+ require "habarico/contrib/preferences/errors"
15
+ require "habarico/contrib/preferences/config"
16
+ require "habarico/contrib/preferences/registry"
17
+ require "habarico/contrib/preferences/user_preference"
18
+ require "habarico/contrib/preferences/schemas"
19
+ require "habarico/contrib/preferences/base_preferences"
20
+ require "habarico/contrib/preferences/serializer"
21
+ require "habarico/contrib/preferences/routes"
22
+
23
+ # NOTE: UserPreferencesController lives in app/controllers so the Rails engine autoloads it
24
+ # (its superclass Habarico::ApplicationController is only available under a booted app). It is
25
+ # not required here; the routes above reference it by path and it resolves on the first request.
26
+
27
+ # Register the contrib's translations (opt-in module, not picked up by the engine's
28
+ # config/locales). Host apps can override any key in their own files.
29
+ I18n.load_path += Dir[File.expand_path("preferences/locales/*.yml", __dir__)]
30
+
31
+ # Register built-in preference keys on load (mirrors the Python AppConfig.ready hook).
32
+ Habarico::Contrib::Preferences.register_base_preferences
@@ -0,0 +1,584 @@
1
+ module Habarico
2
+ module Controller
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ include Eluvia::ErrorHandler
7
+ include Eluvia::PaginationHandler
8
+ include Habarico::Authenticatable
9
+
10
+ # Order matters: `set_list_pagination` hands the `order_by` parameter to `set_pagination`,
11
+ # which refuses anything that is not `field:direction`, so the validator below only ever
12
+ # sees well-formed entries and is left with the field names (HAB-915).
13
+ before_action :set_list_pagination, only: [:index]
14
+ before_action :validate_order_by_param, only: [:index]
15
+ before_action :set_record, only: %i[show update destroy]
16
+ end
17
+
18
+ # *************************************************************************
19
+ # Actions
20
+ # *************************************************************************
21
+
22
+ def index
23
+ read_allowed = context.can?(:read)
24
+ # Without read permission listing is forbidden outright (same as the Python backend), unless
25
+ # the resource explicitly opts into ID-based reads via `allow_read_by_id`.
26
+ raise Eluvia::Errors::Forbidden.new('List not allowed.') unless read_allowed || definition.allow_read_by_id
27
+
28
+ filter_params = sanitize_filter_params.to_h.symbolize_keys
29
+ if read_allowed
30
+ # Habarico owns the typed fulltext search over `search_fields` (see SearchMatcher), so the
31
+ # search term is pulled out and applied separately - it must not reach the base library
32
+ # `filter_2!`, whose `apply_search` uses a different field list and always unaccents.
33
+ filter_params = extract_search!(filter_params)
34
+ else
35
+ # `allow_read_by_id` without read permission: only allow filtering by IDs, and set empty
36
+ # result if no IDs provided. This supports references across multiple services with limited
37
+ # permissions - a caller holding an ID(s) of the object may see its data, but not list all
38
+ # objects (this is what `ReferenceField` on the frontend uses).
39
+ filter_params = definition.model.parse_filter_term(filter_params[:filter].to_s, raise_on_invalid: false).symbolize_keys if filter_params.key?(:filter)
40
+ filter_params = { id__in: [] }.merge(filter_params.slice(:id__in))
41
+ end
42
+ @records = definition.model.all
43
+
44
+ # Policy scope
45
+ if definition.policy_scope.present?
46
+ @records = definition.policy_scope.call(@records, context)
47
+ end
48
+
49
+ # Scopes
50
+ if definition.scopes.present? && !definition.scopes.empty?
51
+ definition.scopes.each do |scope|
52
+ @records = @records.public_send(scope) if @records.respond_to?(scope)
53
+ end
54
+ end
55
+
56
+ # Filtering (filter_2!, typed fulltext search, default filter)
57
+ @records = apply_filtering(@records, filter_params)
58
+
59
+ # Includes
60
+ if definition.includes.present? && !definition.includes.empty?
61
+ @records = @records.includes(*definition.includes)
62
+ end
63
+
64
+ # Ordering
65
+ @records = if @records.respond_to?(:order_2)
66
+ @records.order_2(@order_by)
67
+ else
68
+ @records.order(@order_by)
69
+ end
70
+
71
+ # Pagination
72
+ @records = @records.page(@page).per(@limit).padding(@padding)
73
+
74
+ # Custom prefetch
75
+ if definition.prefetch.respond_to?(:call)
76
+ definition.prefetch.call(@records)
77
+ end
78
+
79
+ render_list
80
+ end
81
+
82
+ def show
83
+ # `show` requires can_read, same as index. `allow_read_by_id` opts into ID-based access
84
+ # without read permission (e.g. cross-service references) - possession of the ID is treated
85
+ # as the credential, while `policy_scope` in `set_record` still bounds what is reachable.
86
+ raise Eluvia::Errors::Forbidden.new('Show not allowed.') unless context.can?(:read) || definition.allow_read_by_id
87
+
88
+ render_detail
89
+ end
90
+
91
+ def create
92
+ raise Eluvia::Errors::Forbidden.new('Create not allowed.') unless context.can?(:create)
93
+ raise Eluvia::Errors::BadRequest.new('Create not possible.') unless is_create_possible?
94
+ @record = definition.model.new(sanitize_record_params.merge(complement_record_params))
95
+ if @record.save
96
+ render_detail
97
+ else
98
+ throw_validation_error
99
+ end
100
+ end
101
+
102
+ def update
103
+ raise Eluvia::Errors::Forbidden.new('Update not allowed.') unless context.can?(:update)
104
+ raise Eluvia::Errors::BadRequest.new('Update not possible.') unless is_update_possible?(@record)
105
+ if @record.update(sanitize_record_params.merge(complement_record_params))
106
+ render_detail
107
+ else
108
+ throw_validation_error
109
+ end
110
+ end
111
+
112
+ def destroy
113
+ raise Eluvia::Errors::Forbidden.new('Delete not allowed.') unless context.can?(:delete)
114
+ raise Eluvia::Errors::BadRequest.new('Delete not possible.') unless is_delete_possible?(@record)
115
+ @record.destroy
116
+ render_detail
117
+ end
118
+
119
+ protected
120
+
121
+ # *************************************************************************
122
+ # Renderers
123
+ # *************************************************************************
124
+
125
+ # `fields` defaults straight from the query string — kept out of
126
+ # `sanitize_filter_params`/`filter_2!` entirely, the same way `search` is
127
+ # held apart from filter term validation, since it controls serialization
128
+ # rather than which records are returned.
129
+ def serialize_record(view_type, record, fields: params[:fields])
130
+ context = _context(record)
131
+
132
+ # Tags
133
+ tags = definition.tags.respond_to?(:call) ? definition.tags.call(context) : []
134
+
135
+ # Representation
136
+ representation = record.respond_to?(:representation) ? record.representation : record.to_s
137
+
138
+ # Schema
139
+ schema = Habarico::SchemaService.instance.get_object_schema(controller_name, view_type, context).as_json
140
+
141
+ # Trigger action
142
+ trigger_action_schema = get_trigger_action_schema(record)
143
+ trigger_action = trigger_action_schema ? trigger_action_schema.as_json.deep_compact(compactable: [nil, {}].freeze) : nil
144
+
145
+ meta_data = {
146
+ 'repr' => representation,
147
+ 'schema' => schema,
148
+ 'tags' => tags,
149
+ 'trigger_action' => trigger_action
150
+ }
151
+
152
+ finalize_meta(record_serializer.serialize(record, meta_data, fields: fields), view_type, record)
153
+ end
154
+
155
+ def finalize_meta(result, view_type, record)
156
+ return result unless result.key?('meta')
157
+
158
+ meta = result['meta']
159
+ meta['schema'] = meta['schema'].deep_compact(compactable: [nil, {}].freeze) if meta['schema']
160
+
161
+ # Search highlights sidecar: present only on list responses when a search is active and at
162
+ # least one `search_fields` path matched. Full (untrimmed) field value, mirroring the Python
163
+ # `meta.highlights` (MR !198). Bypasses `fields=meta(...)` pruning, same as before HAB-614.
164
+ if view_type == :list
165
+ highlights = list_search_highlights(record)
166
+ meta['highlights'] = highlights if highlights
167
+ end
168
+
169
+ result['meta'] = meta.compact
170
+ result
171
+ end
172
+
173
+ # Applies filter_2!, the typed fulltext search and the resource default filter to `scope`.
174
+ def apply_filtering(scope, filter_params)
175
+ scope = scope.filter_2!(filter_params, current_user: current_user) if scope.respond_to?(:filter_2!)
176
+
177
+ # Typed fulltext search (owned by Habarico, see SearchMatcher) - applied after the other
178
+ # filters so it further narrows the already-filtered set (AND semantics).
179
+ scope = apply_search_scope(scope)
180
+
181
+ # Default filter - applied after filter_2! so it is never dropped when a :filter string param
182
+ # is present (filter_2! discards non-:filter keys in that branch, which would silently bypass
183
+ # default_filter).
184
+ if definition.default_filter.present? && scope.respond_to?(:filter_2)
185
+ scope = scope.filter_2(definition.default_filter, current_user: current_user)
186
+ end
187
+
188
+ scope
189
+ end
190
+
191
+ # Narrows `scope` with the typed fulltext SearchMatcher when a search is active; otherwise
192
+ # returns it unchanged.
193
+ def apply_search_scope(scope)
194
+ return scope if @search_query.blank? || definition.search_fields.blank?
195
+
196
+ Habarico::Search::SearchMatcher.new(
197
+ model: definition.model,
198
+ search_fields: definition.search_fields
199
+ ).apply(scope, @search_query)
200
+ end
201
+
202
+ # Full-value highlights for a list record, or nil when there is no active search query, no
203
+ # `search_fields` path matched, or no highlighter is registered.
204
+ #
205
+ # Match highlighting is a paid feature: the free gem leaves `Config.search_highlighter_class`
206
+ # at nil and the `highlights` key is simply absent, while per-resource search keeps working
207
+ # (HAB-812). `habarico-enterprise` registers its `SearchHighlighter` there.
208
+ def list_search_highlights(record)
209
+ return nil if @search_query.blank? || definition.search_fields.blank?
210
+
211
+ highlighter_class = Habarico::Config.search_highlighter_class!
212
+ return nil if highlighter_class.nil?
213
+
214
+ entries = highlighter_class.new(
215
+ model: definition.model,
216
+ search_fields: definition.search_fields
217
+ ).highlight(record, @search_query, full: true)
218
+
219
+ entries.presence
220
+ end
221
+
222
+ def render_detail
223
+ render json: serialize_record(:detail, @record)
224
+ end
225
+
226
+ def render_list
227
+ render json: {
228
+ meta: {
229
+ limit: @limit,
230
+ offset: @offset,
231
+ total_count: @records.total_count,
232
+ },
233
+ objects: @records.map { |record| serialize_record(:list, record) }
234
+ }
235
+ end
236
+
237
+ def render_response(response)
238
+ if response.action
239
+ action = definition.normalized_actions.find { |a| a.name.to_s == response.action.to_s }
240
+ if action
241
+ resource_path = Habarico::ResourceService.instance.get_resource_path(controller_name)
242
+ action_schema_generator = definition.action_schema_generator.new(definition, resource_path)
243
+ trigger_action = action_schema_generator.generate(action, context).as_json.deep_compact(compactable: [nil, {}].freeze)
244
+ end
245
+ end
246
+ render json: {
247
+ state: response.state,
248
+ message: response.message,
249
+ trigger_action: trigger_action
250
+ }.deep_compact
251
+ end
252
+
253
+ def get_trigger_action_schema(_record)
254
+ nil # override in subclasses to return an ActionSchema instance
255
+ end
256
+
257
+ # Checks the `order_by` query parameter against what this resource offers for sorting.
258
+ #
259
+ # The notation itself - `field:direction`, exactly one `:`, a lower-case `asc`/`desc` - and the
260
+ # name of the parameter in camel-case mode belong to `set_pagination`, which runs first and
261
+ # answers its own 422 (HAB-915). What is left here is the field whitelist, the part only
262
+ # habarico knows: the `sort_by` values its schema advertises.
263
+ def validate_order_by_param
264
+ # Forced sorting is not read off the request at all, so refusing the parameter here would
265
+ # refuse a request the backend is about to serve.
266
+ return if definition.force_sorting
267
+
268
+ order_by_param = params[Eluvia::PaginationHandler.order_by_param_name]
269
+ return if order_by_param.blank?
270
+
271
+ # Every unknown field at once, the way the Python backend answers - a client correcting one
272
+ # column per request is the alternative.
273
+ errors = {}
274
+ Habarico::OrderByHelper.parse(order_by_param).map(&:first).each do |field_name|
275
+ next if valid_order_by_field?(field_name)
276
+
277
+ errors["order_by__#{field_name}"] = I18n.t('habarico.errors.order_by.unknown_field', default: 'Unknown field name.')
278
+ end
279
+
280
+ raise Eluvia::Errors::UnprocessableEntity.new(errors) if errors.present?
281
+ end
282
+
283
+ def valid_order_by_field?(field_name)
284
+ order_by_whitelist.nil? || order_by_whitelist.include?(field_name)
285
+ end
286
+
287
+ # Values this resource accepts in `order_by`, or nil when it exposes no whitelist at all - see
288
+ # `Habarico::SortingHelper`. Resolved once per request, the definition does not change under it.
289
+ #
290
+ # The tiebreaker is added here rather than in the helper so that a controller overriding
291
+ # `list_order_by_tiebreaker` does not have to repeat itself: what the list is ordered by anyway
292
+ # is what a client may ask for.
293
+ def order_by_whitelist
294
+ return @order_by_whitelist if defined?(@order_by_whitelist)
295
+
296
+ allowed_fields = Habarico::SortingHelper.allowed_order_by_fields(definition)
297
+ @order_by_whitelist = allowed_fields && (allowed_fields + list_order_by_tiebreaker.map(&:to_s)).uniq
298
+ end
299
+
300
+ def throw_validation_error
301
+ errors = @record.errors.to_hash
302
+ errors_with_variants = {}
303
+ errors.each do |attr_name, error|
304
+ Habarico::AttributesHelper.generate_attr_variants(definition.model, attr_name).each do |attr_name_variant|
305
+ errors_with_variants[attr_name_variant] = error
306
+ end
307
+ end
308
+ raise Eluvia::Errors::UnprocessableEntity.new(errors_with_variants)
309
+ end
310
+
311
+ # *************************************************************************
312
+ # Pagination
313
+ # *************************************************************************
314
+
315
+ # Resolves the page size of `index` and hands it to the base library (HAB-946).
316
+ #
317
+ # Each limit is passed as a keyword only when habarico has an opinion about it. What is left
318
+ # out falls through to `set_pagination` itself - its `default_limit: 20` and its
319
+ # `pagination_max_limit`, which an application controller may have overridden and which is
320
+ # also the only place the ceiling can be removed altogether
321
+ # (`Eluvia::Base::Config.max_page_limit = nil`).
322
+ #
323
+ # Note the method names: `Eluvia::PaginationHandler` is included into the controller *after*
324
+ # this concern, so its `pagination_max_limit` shadows anything of that name declared here -
325
+ # which is why habarico resolves its own limits under distinct names and passes them in
326
+ # explicitly instead of overriding the handler's method.
327
+ def set_list_pagination
328
+ options = {
329
+ # No fallback for `set_pagination` to concatenate - habarico appends its own behind the
330
+ # resolved ordering instead, see `append_order_by_tiebreaker`.
331
+ default_order_by: definition.default_sorting,
332
+ fallback_order_by: '',
333
+ force_order_by: definition.force_sorting
334
+ }
335
+
336
+ default_limit = list_pagination_default_limit
337
+ options[:default_limit] = default_limit unless default_limit.nil?
338
+
339
+ max_limit = list_pagination_max_limit
340
+ options[:max_limit] = max_limit unless max_limit.nil?
341
+
342
+ set_pagination(**options)
343
+ append_order_by_tiebreaker
344
+ end
345
+
346
+ # Orders by the primary key behind whatever else the list is ordered by, so that two records
347
+ # comparing equal keep a stable position and a page neither repeats nor skips one (HAB-915).
348
+ #
349
+ # This is what `fallback_order_by` is for, but handing the key to `set_pagination` cannot do
350
+ # the job: that string is concatenated onto the ordering before it is parsed into a hash keyed
351
+ # by field name, so a fallback of `id:asc` would overwrite the direction of a request already
352
+ # sorting by `id` - `?order_by=id:desc` would answer ascending. Appending afterwards leaves a
353
+ # field the ordering already names alone.
354
+ def append_order_by_tiebreaker
355
+ list_order_by_tiebreaker.each { |field_name| @order_by[field_name.to_s] ||= :asc }
356
+ end
357
+
358
+ # Field names appended to the ordering of `index` to break ties. The primary key by default -
359
+ # it is unique and every model has one, `Array()` covering a composite key. A model with no
360
+ # primary key at all gets nothing, and so does a model without ActiveRecord reflection.
361
+ #
362
+ # Until HAB-915 this was `created_at:asc`, hardcoded: a model without that column answered 422
363
+ # (`order_by__created_at` - "Does not exist.") or a database error on *every* list request,
364
+ # and records sharing a timestamp could still swap places between two pages. Override to sort
365
+ # ties differently; to keep `created_at` in the ordering, name it in `default_sorting`.
366
+ def list_order_by_tiebreaker
367
+ Array(definition.model.try(:primary_key))
368
+ end
369
+
370
+ # Page size for a request without a `limit`, or nil to leave it to the base library.
371
+ def list_pagination_default_limit
372
+ definition.default_page_limit || Habarico::Config.default_page_limit
373
+ end
374
+
375
+ # Ceiling for `limit`, or nil to leave it to the base library.
376
+ def list_pagination_max_limit
377
+ definition.max_page_limit || Habarico::Config.max_page_limit
378
+ end
379
+
380
+ # *************************************************************************
381
+ # Model setters
382
+ # *************************************************************************
383
+
384
+ def set_record
385
+ @pk = params[:id]
386
+ scope = definition.model.all
387
+
388
+ # Policy scope - applied unconditionally, same as index, so a record outside the Pundit
389
+ # scope (other tenant, soft-deleted, ...) 404s. Built from a fresh, non-memoized context
390
+ # since `context` is memoized on first call and must reflect the actual @record once found.
391
+ scope = definition.policy_scope.call(scope, _context(nil)) if definition.policy_scope.present?
392
+
393
+ @record = if scope.respond_to?(:filter_2)
394
+ scope.filter_2(definition.default_filter).find(@pk)
395
+ else
396
+ scope.find(@pk)
397
+ end
398
+ end
399
+
400
+ # *************************************************************************
401
+ # Sanitizers
402
+ # *************************************************************************
403
+
404
+ def sanitize_record_params
405
+ write_fields = (definition.fields + definition.write_only_fields).uniq
406
+ # `ActionController::Parameters#permit` silently drops a Hash value for a bare field name
407
+ # (e.g. a `file_attr`'s `{filename:, content:}` or `{upload_key:}` payload) unless its
408
+ # nested keys are explicitly permitted - expand those fields into a nested permit spec.
409
+ permit_spec = write_fields.map { |field| record_param_permit_spec(field) }
410
+ params.permit(*permit_spec)
411
+ end
412
+
413
+ def record_param_permit_spec(field)
414
+ # Hash entries (`{ name => subfields }`) are already a nested permit spec - keep intact.
415
+ return field if field.is_a?(::Hash)
416
+
417
+ # Normalize Symbol/String/FieldReference entries so `permit` never receives a FieldReference.
418
+ name = Habarico::AttributesHelper.field_name(field)
419
+ # `ActionController::Parameters#permit` only allows scalar values for a bare field name -
420
+ # a Hash value (e.g. a `file_attr` field's base64 `{filename:, content:}` or chunked-upload
421
+ # `{upload_key:}` payload) is silently dropped unless its nested keys are explicitly
422
+ # permitted. Expand file_attr fields into a nested permit spec; leave every other field as-is.
423
+ if definition.model.respond_to?(:has_file_attr?) && definition.model.has_file_attr?(name)
424
+ { name => %i[filename content upload_key] }
425
+ else
426
+ name
427
+ end
428
+ end
429
+
430
+ def complement_record_params
431
+ {} # override in subclasses if needed
432
+ end
433
+
434
+ def sanitize_filter_params
435
+ pk = definition.model.primary_key.to_sym
436
+ filters = definition.filters + [pk, :"#{pk}__in"]
437
+
438
+ permitted_params = definition.model.permitted_filter_params
439
+ selected_params = permitted_params.select { |f| filters.include?(filter_param_to_sym(f)) }
440
+ selected_keys = selected_params.map { |p| filter_param_to_sym(p) }
441
+
442
+ disallowed = (params.keys.map(&:to_sym) & permitted_params.map { |p| filter_param_to_sym(p) }) - selected_keys - %i[filter search]
443
+ raise_filter_errors(disallowed) if disallowed.any?
444
+
445
+ if params.key?(:filter)
446
+ filter_term_keys = definition.model.parse_filter_term(params[:filter].to_s, raise_on_invalid: false).symbolize_keys.keys
447
+ disallowed_terms = filter_term_keys.map(&:to_sym) - selected_keys - [:search]
448
+ raise_filter_errors(disallowed_terms) if disallowed_terms.any?
449
+ end
450
+
451
+ params.permit(selected_params + %i[filter search])
452
+ end
453
+
454
+ def filter_param_to_sym(param)
455
+ param.is_a?(Hash) ? param.keys.first : param.to_sym
456
+ end
457
+
458
+ # Pulls the search out of the filter params: stashes the effective query in `@search_query` and
459
+ # returns the params with the search removed (so it never reaches the base library's `filter_2!`). Both
460
+ # transport forms (`:search` param and a `search="..."` term in the `:filter` string) are handled
461
+ # by `Habarico::FilterParamsHelper`.
462
+ def extract_search!(filter_params)
463
+ @search_query = Habarico::FilterParamsHelper.extract(filter_params, :search)
464
+ Habarico::FilterParamsHelper.strip(filter_params, :search)
465
+ end
466
+
467
+ def raise_filter_errors(keys)
468
+ errors = keys.to_h { |k| [k.to_s, I18n.t('habarico.errors.filter.unknown_term', default: 'Unknown filter term.')] }
469
+ raise Eluvia::Errors::UnprocessableEntity.new(errors)
470
+ end
471
+
472
+ # *************************************************************************
473
+ # Context
474
+ # *************************************************************************
475
+
476
+ def _context(record)
477
+ Habarico::Context.new(definition: definition, current_user: current_user,
478
+ record_pk: record&.id, record: record)
479
+ end
480
+
481
+ def context
482
+ @context ||= _context(@record)
483
+ end
484
+
485
+ # *************************************************************************
486
+ # Restrictions
487
+ # *************************************************************************
488
+
489
+ def is_create_possible?
490
+ true # override in subclasses if needed
491
+ end
492
+
493
+ def is_update_possible?(_record)
494
+ true # override in subclasses if needed
495
+ end
496
+
497
+ def is_delete_possible?(_record)
498
+ true # override in subclasses if needed
499
+ end
500
+
501
+ # *************************************************************************
502
+ # Class objects
503
+ # *************************************************************************
504
+
505
+ def definition
506
+ self.class.definition
507
+ end
508
+
509
+ def serializer
510
+ self.class.serializer
511
+ end
512
+
513
+ def meta_serializer
514
+ self.class.meta_serializer
515
+ end
516
+
517
+ def record_serializer
518
+ self.class.record_serializer
519
+ end
520
+
521
+ class_methods do
522
+
523
+ # ***********************************************************************
524
+ # Definition
525
+ # ***********************************************************************
526
+
527
+ def definition
528
+ @definition ||= Habarico::Definition.new
529
+ end
530
+
531
+ Habarico::Definition.attrs.each do |attr|
532
+ define_method(attr) do |value = nil, &block|
533
+ definition.send("#{attr}=", value) unless value.nil?
534
+ definition.send("#{attr}=", block) unless block.nil?
535
+ end
536
+ end
537
+
538
+ # ***********************************************************************
539
+ # Serializer
540
+ # ***********************************************************************
541
+
542
+ def serializer
543
+ @serializer ||= Habarico::Serializers::Serializer.new(definition)
544
+ end
545
+
546
+ def meta_serializer
547
+ @meta_serializer ||= Habarico::Serializers::MetaSerializer.new
548
+ end
549
+
550
+ def record_serializer
551
+ @record_serializer ||= Habarico::Serializers::RecordSerializer.new(serializer: serializer, meta_serializer: meta_serializer)
552
+ end
553
+
554
+ # ***********************************************************************
555
+ # Registered actions
556
+ # ***********************************************************************
557
+
558
+ def registered_actions
559
+ @registered_actions ||= []
560
+ end
561
+
562
+ def register_action(name, is_detail: true, method: :put, model: nil, permission: :read, &proc)
563
+ self.registered_actions << {
564
+ name: name,
565
+ is_detail: is_detail,
566
+ model: model,
567
+ permission: permission,
568
+ method: method
569
+ }
570
+ define_method(name) do
571
+ set_record if is_detail
572
+ raise Eluvia::Errors::StandardError.new("Action `#{name}` is not correctly implemented.") unless proc.respond_to?(:call)
573
+ raise Eluvia::Errors::Forbidden.new("Action `#{name}` not allowed.") unless context.can?(permission)
574
+ response = self.instance_exec(&proc)
575
+ response = Habarico::Response.new(message: response) if response.is_a?(::String)
576
+ response = Habarico::Response.new unless response.is_a?(Habarico::Response)
577
+ render_response(response)
578
+ end
579
+ end
580
+
581
+ end
582
+
583
+ end
584
+ end
@@ -0,0 +1,18 @@
1
+ module Habarico
2
+ class ActionReference
3
+ include DefinitionModel
4
+
5
+ attr_accessor :name, :label, :icon, :color, :action, :if, :permission, :menu_items, :view_mode, :dialog_width, :view_choices, :view_parameters, :view_keep_linked_parent
6
+
7
+ def initialize(params = {})
8
+ @permission = case params[:name].to_s
9
+ when 'create' then :create
10
+ when 'edit' then :update
11
+ when 'delete' then :delete
12
+ else :read
13
+ end
14
+ super(params)
15
+ end
16
+
17
+ end
18
+ end
@@ -0,0 +1,14 @@
1
+ module Habarico
2
+ class Action
3
+ include DefinitionModel
4
+
5
+ attr_accessor :type, :label, :icon, :color, :is_detail, :name, :show_in_detail, :show_in_list,
6
+ :confirm_dialog_title, :confirm_dialog_message, :permission
7
+ attr_accessor :view_name, :view_mode, :dialog_width, :view_parameters, :view_keep_linked_parent # attributes for type = :view
8
+ attr_accessor :endpoint_action_name, :endpoint_method, :endpoint_reference # attributes for type = :view or :resource
9
+ attr_accessor :is_background, :data # attributes for type = :resource
10
+ attr_accessor :link_url, :link_target, :if # attributes for type = :link
11
+ attr_accessor :upload_images_reference, :upload_images_target, :upload_images_accept, :upload_images_image_field # attributes for type = :"upload-images"
12
+
13
+ end
14
+ end
@@ -0,0 +1,18 @@
1
+ module Habarico
2
+ class CreateAction < ViewAction
3
+ include DefinitionModel
4
+
5
+ def initialize(params = {})
6
+ @name = 'create'
7
+ @icon = 'Add'
8
+ @is_detail = false # -> not tied to a specific resource instance
9
+ @show_in_detail = false # -> do not show in detail views
10
+ @show_in_list = true # -> show in list views (on top of the list)
11
+ @view_name = 'create' # -> connect it to the Habarico::CreateView or any other view named 'edit'
12
+ @view_mode = :redirect # -> open the view on the new page
13
+ @permission = :create
14
+ super(params)
15
+ end
16
+
17
+ end
18
+ end