super_admin 0.2.0

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 (100) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +216 -0
  3. data/Rakefile +30 -0
  4. data/app/assets/stylesheets/super_admin/application.css +15 -0
  5. data/app/assets/stylesheets/super_admin/tailwind.css +1 -0
  6. data/app/assets/stylesheets/super_admin/tailwind.source.css +25 -0
  7. data/app/controllers/super_admin/application_controller.rb +89 -0
  8. data/app/controllers/super_admin/associations_controller.rb +136 -0
  9. data/app/controllers/super_admin/audit_logs_controller.rb +39 -0
  10. data/app/controllers/super_admin/base_controller.rb +133 -0
  11. data/app/controllers/super_admin/dashboard_controller.rb +29 -0
  12. data/app/controllers/super_admin/exports_controller.rb +109 -0
  13. data/app/controllers/super_admin/resources_controller.rb +201 -0
  14. data/app/dashboards/super_admin/base_dashboard.rb +200 -0
  15. data/app/errors/super_admin/configuration_error.rb +6 -0
  16. data/app/helpers/super_admin/application_helper.rb +84 -0
  17. data/app/helpers/super_admin/exports_helper.rb +16 -0
  18. data/app/helpers/super_admin/resources_helper.rb +204 -0
  19. data/app/helpers/super_admin/route_helper.rb +7 -0
  20. data/app/javascript/super_admin/application.js +263 -0
  21. data/app/jobs/super_admin/application_job.rb +4 -0
  22. data/app/jobs/super_admin/generate_super_admin_csv_export_job.rb +100 -0
  23. data/app/mailers/super_admin/application_mailer.rb +6 -0
  24. data/app/models/super_admin/application_record.rb +5 -0
  25. data/app/models/super_admin/audit_log.rb +35 -0
  26. data/app/models/super_admin/csv_export.rb +67 -0
  27. data/app/services/super_admin/auditing.rb +74 -0
  28. data/app/services/super_admin/authorization.rb +113 -0
  29. data/app/services/super_admin/authorization_adapters/base_adapter.rb +100 -0
  30. data/app/services/super_admin/authorization_adapters/default_adapter.rb +77 -0
  31. data/app/services/super_admin/authorization_adapters/proc_adapter.rb +65 -0
  32. data/app/services/super_admin/authorization_adapters/pundit_adapter.rb +81 -0
  33. data/app/services/super_admin/csv_export_creator.rb +45 -0
  34. data/app/services/super_admin/dashboard_registry.rb +90 -0
  35. data/app/services/super_admin/dashboard_resolver.rb +100 -0
  36. data/app/services/super_admin/filter_builder.rb +185 -0
  37. data/app/services/super_admin/form_builder.rb +59 -0
  38. data/app/services/super_admin/form_fields/array_field.rb +35 -0
  39. data/app/services/super_admin/form_fields/association_field.rb +146 -0
  40. data/app/services/super_admin/form_fields/base_field.rb +53 -0
  41. data/app/services/super_admin/form_fields/boolean_field.rb +29 -0
  42. data/app/services/super_admin/form_fields/date_field.rb +15 -0
  43. data/app/services/super_admin/form_fields/date_time_field.rb +15 -0
  44. data/app/services/super_admin/form_fields/enum_field.rb +27 -0
  45. data/app/services/super_admin/form_fields/factory.rb +102 -0
  46. data/app/services/super_admin/form_fields/nested_field.rb +120 -0
  47. data/app/services/super_admin/form_fields/number_field.rb +29 -0
  48. data/app/services/super_admin/form_fields/text_area_field.rb +19 -0
  49. data/app/services/super_admin/model_inspector.rb +182 -0
  50. data/app/services/super_admin/queries/base_query.rb +45 -0
  51. data/app/services/super_admin/queries/filter_query.rb +188 -0
  52. data/app/services/super_admin/queries/resource_scope_query.rb +74 -0
  53. data/app/services/super_admin/queries/search_query.rb +146 -0
  54. data/app/services/super_admin/queries/sort_query.rb +41 -0
  55. data/app/services/super_admin/resource_configuration.rb +63 -0
  56. data/app/services/super_admin/resource_exporter.rb +78 -0
  57. data/app/services/super_admin/resource_query.rb +40 -0
  58. data/app/services/super_admin/resources/association_inspector.rb +112 -0
  59. data/app/services/super_admin/resources/collection_presenter.rb +63 -0
  60. data/app/services/super_admin/resources/context.rb +63 -0
  61. data/app/services/super_admin/resources/filter_params.rb +29 -0
  62. data/app/services/super_admin/resources/permitted_attributes.rb +104 -0
  63. data/app/services/super_admin/resources/value_normalizer.rb +121 -0
  64. data/app/services/super_admin/sensitive_attributes.rb +166 -0
  65. data/app/views/layouts/super_admin.html.erb +74 -0
  66. data/app/views/super_admin/audit_logs/index.html.erb +143 -0
  67. data/app/views/super_admin/dashboard/index.html.erb +79 -0
  68. data/app/views/super_admin/exports/index.html.erb +84 -0
  69. data/app/views/super_admin/exports/show.html.erb +57 -0
  70. data/app/views/super_admin/resources/_form.html.erb +42 -0
  71. data/app/views/super_admin/resources/destroy.turbo_stream.erb +17 -0
  72. data/app/views/super_admin/resources/edit.html.erb +37 -0
  73. data/app/views/super_admin/resources/index.html.erb +189 -0
  74. data/app/views/super_admin/resources/new.html.erb +31 -0
  75. data/app/views/super_admin/resources/show.html.erb +106 -0
  76. data/app/views/super_admin/shared/_breadcrumbs.html.erb +12 -0
  77. data/app/views/super_admin/shared/_custom_styles.html.erb +132 -0
  78. data/app/views/super_admin/shared/_flash.html.erb +55 -0
  79. data/app/views/super_admin/shared/_form_field.html.erb +35 -0
  80. data/app/views/super_admin/shared/_navigation.html.erb +92 -0
  81. data/app/views/super_admin/shared/_nested_fields.html.erb +59 -0
  82. data/app/views/super_admin/shared/_nested_record_fields.html.erb +45 -0
  83. data/config/importmap.rb +4 -0
  84. data/config/initializers/rack_attack.rb +134 -0
  85. data/config/initializers/super_admin.rb +117 -0
  86. data/config/locales/super_admin.en.yml +197 -0
  87. data/config/locales/super_admin.fr.yml +197 -0
  88. data/config/routes.rb +22 -0
  89. data/lib/generators/super_admin/dashboard_generator.rb +50 -0
  90. data/lib/generators/super_admin/install_generator.rb +58 -0
  91. data/lib/generators/super_admin/templates/20240101000001_create_super_admin_audit_logs.rb +24 -0
  92. data/lib/generators/super_admin/templates/20240101000002_create_super_admin_csv_exports.rb +33 -0
  93. data/lib/generators/super_admin/templates/super_admin.rb +58 -0
  94. data/lib/super_admin/dashboard_creator.rb +256 -0
  95. data/lib/super_admin/engine.rb +53 -0
  96. data/lib/super_admin/install_task.rb +96 -0
  97. data/lib/super_admin/version.rb +3 -0
  98. data/lib/super_admin.rb +7 -0
  99. data/lib/tasks/super_admin_tasks.rake +38 -0
  100. metadata +239 -0
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module SuperAdmin
6
+ module Queries
7
+ # Query object for applying typed filters (string, numeric, date, boolean, enum).
8
+ class FilterQuery < BaseQuery
9
+ STRING_TYPES = %i[string text].freeze
10
+ NUMERIC_TYPES = %i[integer float decimal].freeze
11
+ DATE_TYPES = %i[date datetime].freeze
12
+ BOOLEAN_TYPES = %i[boolean].freeze
13
+
14
+ attr_reader :filters, :filter_definitions
15
+
16
+ # @param scope [ActiveRecord::Relation]
17
+ # @param model_class [Class]
18
+ # @param filters [Hash] Paramètres de filtrage
19
+ # @param filter_definitions [Array<SuperAdmin::FilterBuilder::FilterDefinition>]
20
+ def initialize(scope, model_class, filters = nil, filter_definitions: nil, **kwargs)
21
+ super(scope, model_class)
22
+
23
+ provided_filters = if filters.is_a?(Hash) && !kwargs.key?(:filters)
24
+ filters
25
+ else
26
+ kwargs[:filters] || {}
27
+ end
28
+
29
+ provided_definitions = filter_definitions || kwargs[:filter_definitions]
30
+
31
+ @filters = provided_filters.to_h.stringify_keys
32
+ @filter_definitions = provided_definitions || SuperAdmin::FilterBuilder.definitions_for(model_class)
33
+ end
34
+
35
+ # Applies all filters
36
+ # @return [ActiveRecord::Relation]
37
+ def call
38
+ return scope if filters.blank?
39
+
40
+ filter_definitions.each do |definition|
41
+ apply_filter_for_definition(definition)
42
+ end
43
+
44
+ scope
45
+ end
46
+
47
+ private
48
+
49
+ # Applies filter based on its definition
50
+ # @param definition [SuperAdmin::FilterBuilder::FilterDefinition]
51
+ def apply_filter_for_definition(definition)
52
+ case definition.type
53
+ when *STRING_TYPES
54
+ apply_string_filter(definition)
55
+ when *NUMERIC_TYPES
56
+ apply_numeric_filter(definition)
57
+ when *DATE_TYPES
58
+ apply_date_filter(definition)
59
+ when *BOOLEAN_TYPES
60
+ apply_boolean_filter(definition)
61
+ when :enum
62
+ apply_enum_filter(definition)
63
+ end
64
+ end
65
+
66
+ # Applies string filter (contains)
67
+ # @param definition [SuperAdmin::FilterBuilder::FilterDefinition]
68
+ def apply_string_filter(definition)
69
+ key = "#{definition.attribute}_contains"
70
+ value = filters[key]
71
+ return if value.blank?
72
+
73
+ column = column_for(definition.attribute)
74
+ return unless column && STRING_TYPES.include?(column.type)
75
+
76
+ sanitized = ActiveRecord::Base.sanitize_sql_like(value)
77
+ term = "%#{sanitized.downcase}%"
78
+ lowered_column = Arel::Nodes::NamedFunction.new("LOWER", [ arel_table[definition.attribute] ])
79
+ @scope = scope.where(lowered_column.matches(Arel::Nodes.build_quoted(term)))
80
+ end
81
+
82
+ # Applies numeric filter (min/max)
83
+ # @param definition [SuperAdmin::FilterBuilder::FilterDefinition]
84
+ def apply_numeric_filter(definition)
85
+ min_key = "#{definition.attribute}_min"
86
+ max_key = "#{definition.attribute}_max"
87
+
88
+ if filters[min_key].present?
89
+ parsed_min = parse_numeric(filters[min_key], definition.type)
90
+ @scope = scope.where(arel_table[definition.attribute].gteq(parsed_min)) if parsed_min
91
+ end
92
+
93
+ if filters[max_key].present?
94
+ parsed_max = parse_numeric(filters[max_key], definition.type)
95
+ @scope = scope.where(arel_table[definition.attribute].lteq(parsed_max)) if parsed_max
96
+ end
97
+ end
98
+
99
+ # Applies date filter (from/to)
100
+ # @param definition [SuperAdmin::FilterBuilder::FilterDefinition]
101
+ def apply_date_filter(definition)
102
+ from_key = "#{definition.attribute}_from"
103
+ to_key = "#{definition.attribute}_to"
104
+
105
+ if filters[from_key].present?
106
+ parsed_from = parse_temporal(filters[from_key], definition.type)
107
+ @scope = scope.where(arel_table[definition.attribute].gteq(parsed_from)) if parsed_from
108
+ end
109
+
110
+ if filters[to_key].present?
111
+ parsed_to = parse_temporal(filters[to_key], definition.type)
112
+ @scope = scope.where(arel_table[definition.attribute].lteq(parsed_to)) if parsed_to
113
+ end
114
+ end
115
+
116
+ # Applies boolean filter (equals)
117
+ # @param definition [SuperAdmin::FilterBuilder::FilterDefinition]
118
+ def apply_boolean_filter(definition)
119
+ key = "#{definition.attribute}_equals"
120
+ return unless filters.key?(key)
121
+
122
+ parsed = parse_boolean(filters[key])
123
+ @scope = scope.where(definition.attribute => parsed) unless parsed.nil?
124
+ end
125
+
126
+ # Applies enum filter (equals)
127
+ # @param definition [SuperAdmin::FilterBuilder::FilterDefinition]
128
+ def apply_enum_filter(definition)
129
+ key = "#{definition.attribute}_equals"
130
+ value = filters[key]
131
+ return if value.blank?
132
+ return unless definition.options.include?(value)
133
+
134
+ @scope = scope.where(definition.attribute => value)
135
+ end
136
+
137
+ # Parses numeric value based on type
138
+ # @param value [String]
139
+ # @param type [Symbol]
140
+ # @return [Numeric, nil]
141
+ def parse_numeric(value, type)
142
+ case type
143
+ when :integer
144
+ Integer(value)
145
+ when :float
146
+ Float(value)
147
+ when :decimal
148
+ BigDecimal(value)
149
+ end
150
+ rescue ArgumentError, TypeError
151
+ nil
152
+ end
153
+
154
+ # Parses temporal value based on type
155
+ # @param value [String]
156
+ # @param type [Symbol]
157
+ # @return [Date, Time, nil]
158
+ def parse_temporal(value, type)
159
+ case type
160
+ when :date
161
+ Date.parse(value)
162
+ when :datetime
163
+ Time.zone.parse(value)
164
+ end
165
+ rescue ArgumentError, TypeError
166
+ nil
167
+ end
168
+
169
+ # Parses boolean value
170
+ # @param value [String]
171
+ # @return [Boolean, nil]
172
+ def parse_boolean(value)
173
+ return true if %w[true 1 yes oui].include?(value.to_s.downcase)
174
+ return false if %w[false 0 no non].include?(value.to_s.downcase)
175
+
176
+ nil
177
+ end
178
+
179
+ # Returns the ActiveRecord column definition for a given attribute.
180
+ # Ensures we only build filters on real columns, protecting against SQL injection.
181
+ # @param attribute [String, Symbol]
182
+ # @return [ActiveRecord::ConnectionAdapters::Column, nil]
183
+ def column_for(attribute)
184
+ model_class.columns_hash[attribute.to_s]
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperAdmin
4
+ module Queries
5
+ # Orchestrator that composes query objects to build the complete scope.
6
+ class ResourceScopeQuery
7
+ LEGACY_ARGUMENT_ERROR = "ResourceScopeQuery expects an ActiveRecord::Relation or ActiveRecord::Base subclass".freeze
8
+
9
+ attr_reader :model_class, :scope, :search, :sort_column, :direction, :filters
10
+
11
+ # @param scope_or_model_class [ActiveRecord::Relation, Class]
12
+ # @param search [String, nil]
13
+ # @param query [String, nil] Legacy alias for +search+
14
+ # @param sort [String, nil] Legacy alias for +sort_column+
15
+ # @param sort_column [String, nil]
16
+ # @param direction [String, nil]
17
+ # @param sort_direction [String, nil] Legacy alias for +direction+
18
+ # @param filters [Hash]
19
+ def initialize(scope_or_model_class, search: nil, query: nil, sort: nil, sort_column: nil,
20
+ direction: nil, sort_direction: nil, filters: {})
21
+ relation, klass = resolve_scope_and_class(scope_or_model_class)
22
+
23
+ @scope = relation
24
+ @model_class = klass
25
+ @search = search.presence || query
26
+ @sort_column = sort_column || sort
27
+ @direction = direction || sort_direction
28
+ @filters = filters || {}
29
+ end
30
+
31
+ # Builds the complete scope by composing query objects
32
+ # @return [ActiveRecord::Relation]
33
+ def call
34
+ result = scope
35
+ result = apply_search(result)
36
+ result = apply_filters(result)
37
+ apply_sort(result)
38
+ end
39
+
40
+ private
41
+
42
+ # Applies full-text search
43
+ # @param scope [ActiveRecord::Relation]
44
+ # @return [ActiveRecord::Relation]
45
+ def apply_search(scope)
46
+ SearchQuery.new(scope, model_class, term: search).call
47
+ end
48
+
49
+ # Applies typed filters
50
+ # @param scope [ActiveRecord::Relation]
51
+ # @return [ActiveRecord::Relation]
52
+ def apply_filters(scope)
53
+ FilterQuery.new(scope, model_class, filters: filters).call
54
+ end
55
+
56
+ # Applies sorting
57
+ # @param scope [ActiveRecord::Relation]
58
+ # @return [ActiveRecord::Relation]
59
+ def apply_sort(scope)
60
+ SortQuery.new(scope, model_class, sort_column: sort_column, direction: direction).call
61
+ end
62
+
63
+ def resolve_scope_and_class(scope_or_model_class)
64
+ if scope_or_model_class.is_a?(ActiveRecord::Relation)
65
+ [ scope_or_model_class, scope_or_model_class.klass ]
66
+ elsif scope_or_model_class.is_a?(Class) && scope_or_model_class < ActiveRecord::Base
67
+ [ scope_or_model_class.all, scope_or_model_class ]
68
+ else
69
+ raise ArgumentError, LEGACY_ARGUMENT_ERROR
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperAdmin
4
+ module Queries
5
+ # Query object for full-text search on string/text columns
6
+ # and their belongs_to associations.
7
+ class SearchQuery < BaseQuery
8
+ attr_reader :term
9
+
10
+ # @param scope [ActiveRecord::Relation]
11
+ # @param model_or_term [Class, String, nil] Model class or legacy positional search term
12
+ # @param term [String, nil] Search term when providing the model class explicitly
13
+ def initialize(scope, model_or_term = nil, term: nil)
14
+ if model_or_term.is_a?(Class)
15
+ super(scope, model_or_term)
16
+ @term = term
17
+ else
18
+ inferred_class = scope.respond_to?(:klass) ? scope.klass : model_or_term&.class
19
+ super(scope, inferred_class)
20
+ @term = model_or_term || term
21
+ end
22
+ end
23
+
24
+ # Applies full-text search
25
+ # @return [ActiveRecord::Relation]
26
+ def call
27
+ return scope if term.blank?
28
+
29
+ sanitized_term = ActiveRecord::Base.sanitize_sql_like(term)
30
+ original_pattern = "%#{sanitized_term}%"
31
+ lower_pattern = "%#{sanitized_term.downcase}%"
32
+ patterns = [ original_pattern ]
33
+ patterns << lower_pattern unless lower_pattern == original_pattern
34
+
35
+ predicates = []
36
+
37
+ # Search in model columns
38
+ base_predicates = search_in_columns(patterns, lower_pattern)
39
+ predicates << base_predicates if base_predicates.present?
40
+
41
+ # Search in belongs_to associations
42
+ association_data = search_in_associations(patterns, lower_pattern)
43
+ predicates.concat(association_data[:predicates]) if association_data[:predicates].present?
44
+
45
+ return scope if predicates.empty?
46
+
47
+ result = scope
48
+ result = result.left_outer_joins(association_data[:associations]) if association_data[:associations].present?
49
+ result.where(predicates.reduce { |memo, predicate| memo.or(predicate) })
50
+ end
51
+
52
+ private
53
+
54
+ # Searches in model's string/text columns
55
+ # @param patterns [Array<String>]
56
+ # @param lower_pattern [String]
57
+ # @return [Arel::Nodes::Node, nil]
58
+ def search_in_columns(patterns, lower_pattern)
59
+ searchable_columns = columns_of_type(:string, :text)
60
+ return nil if searchable_columns.empty?
61
+
62
+ column_predicates = searchable_columns.map do |column|
63
+ build_column_predicates(column, patterns, lower_pattern)
64
+ end
65
+
66
+ column_predicates.reduce { |memo, predicate| memo.or(predicate) }
67
+ end
68
+
69
+ # Builds predicates for a given column
70
+ # @param column [ActiveRecord::ConnectionAdapters::Column]
71
+ # @param patterns [Array<String>]
72
+ # @param lower_pattern [String]
73
+ # @return [Arel::Nodes::Node]
74
+ def build_column_predicates(column, patterns, lower_pattern)
75
+ lowered_column = Arel::Nodes::NamedFunction.new("LOWER", [ arel_table[column.name] ])
76
+
77
+ column_predicates = patterns.map do |pattern|
78
+ arel_table[column.name].matches(Arel::Nodes.build_quoted(pattern))
79
+ end
80
+
81
+ column_predicates << lowered_column.matches(Arel::Nodes.build_quoted(lower_pattern))
82
+
83
+ column_predicates.reduce { |memo, predicate| memo.or(predicate) }
84
+ end
85
+
86
+ # Searches in belongs_to associations
87
+ # @param patterns [Array<String>]
88
+ # @param lower_pattern [String]
89
+ # @return [Hash] { predicates: Array, associations: Array }
90
+ def search_in_associations(patterns, lower_pattern)
91
+ return { predicates: [], associations: [] } unless model_class.respond_to?(:reflect_on_all_associations)
92
+
93
+ associations = model_class.reflect_on_all_associations(:belongs_to).reject(&:polymorphic?)
94
+
95
+ predicates = []
96
+ joined_associations = []
97
+
98
+ associations.each do |association|
99
+ association_predicates = build_association_predicates(association, patterns, lower_pattern)
100
+ next if association_predicates.nil?
101
+
102
+ predicates << association_predicates
103
+ joined_associations << association.name
104
+ end
105
+
106
+ { predicates: predicates, associations: joined_associations.uniq }
107
+ end
108
+
109
+ # Builds predicates for a given association
110
+ # @param association [ActiveRecord::Reflection]
111
+ # @param patterns [Array<String>]
112
+ # @param lower_pattern [String]
113
+ # @return [Arel::Nodes::Node, nil]
114
+ def build_association_predicates(association, patterns, lower_pattern)
115
+ begin
116
+ associated_class = association.klass
117
+ rescue NameError
118
+ return nil
119
+ end
120
+
121
+ return nil unless associated_class.respond_to?(:columns)
122
+ return nil unless associated_class.table_exists?
123
+
124
+ associated_columns = associated_class.columns.select { |column| %i[string text].include?(column.type) }
125
+ return nil if associated_columns.empty?
126
+
127
+ association_table = associated_class.arel_table
128
+
129
+ column_predicates = associated_columns.map do |column|
130
+ attribute = association_table[column.name]
131
+ lowered = Arel::Nodes::NamedFunction.new("LOWER", [ attribute ])
132
+
133
+ predicates_for_column = patterns.map do |pattern|
134
+ attribute.matches(Arel::Nodes.build_quoted(pattern))
135
+ end
136
+
137
+ predicates_for_column << lowered.matches(Arel::Nodes.build_quoted(lower_pattern))
138
+
139
+ predicates_for_column.reduce { |memo, predicate| memo.or(predicate) }
140
+ end
141
+
142
+ column_predicates.reduce { |memo, predicate| memo.or(predicate) }
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperAdmin
4
+ module Queries
5
+ # Query object for sorting on a given column.
6
+ class SortQuery < BaseQuery
7
+ attr_reader :sort_column, :direction
8
+
9
+ # @param scope [ActiveRecord::Relation]
10
+ # @param model_class [Class]
11
+ # @param sort_column [String, nil] Sort column name
12
+ # @param direction [String, nil] Direction (asc/desc)
13
+ def initialize(scope, model_class, sort_column: nil, direction: nil)
14
+ super(scope, model_class)
15
+ @sort_column = sort_column
16
+ @direction = direction
17
+ end
18
+
19
+ # Applies sorting
20
+ # @return [ActiveRecord::Relation]
21
+ def call
22
+ return default_sort if sort_column.blank?
23
+
24
+ column = model_class.columns_hash[sort_column.to_s]
25
+ return default_sort unless column
26
+
27
+ sanitized_direction = direction == "desc" ? :desc : :asc
28
+ arel_column = arel_table[column.name]
29
+ scope.order(arel_column.public_send(sanitized_direction))
30
+ end
31
+
32
+ private
33
+
34
+ # Default sort by id descending
35
+ # @return [ActiveRecord::Relation]
36
+ def default_sort
37
+ scope.order(id: :desc)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperAdmin
4
+ # Provides configuration helpers for models managed in SuperAdmin.
5
+ class ResourceConfiguration
6
+ DISPLAY_EXCLUDED_ATTRIBUTES = %w[created_at updated_at].freeze
7
+ EDIT_EXCLUDED_ATTRIBUTES = %w[id created_at updated_at].freeze
8
+ SENSITIVE_ATTRIBUTES = %w[
9
+ encrypted_password
10
+ reset_password_token
11
+ reset_password_sent_at
12
+ remember_created_at
13
+ confirmation_token
14
+ confirmation_sent_at
15
+ unconfirmed_email
16
+ unlock_token
17
+ locked_at
18
+ invitation_token
19
+ invitation_sent_at
20
+ invitation_accepted_at
21
+ invitation_message
22
+ authentication_token
23
+ access_token
24
+ refresh_token
25
+ api_key
26
+ ].freeze
27
+ PRIORITY_ATTRIBUTES = %w[id email name title full_name first_name last_name].freeze
28
+
29
+ class << self
30
+ # Returns the list of displayable attributes for a resource.
31
+ # @param model_class [Class]
32
+ # @return [Array<String>]
33
+ def displayable_attributes(model_class)
34
+ base_attributes = model_class.attribute_names.reject do |attr|
35
+ DISPLAY_EXCLUDED_ATTRIBUTES.include?(attr) || SENSITIVE_ATTRIBUTES.include?(attr)
36
+ end
37
+
38
+ prioritized = PRIORITY_ATTRIBUTES.compact.select do |attr|
39
+ base_attributes.include?(attr) || attr == model_class.primary_key
40
+ end
41
+
42
+ (prioritized + base_attributes).uniq
43
+ end
44
+
45
+ # Returns the list of editable attributes (used by dynamic forms).
46
+ # @param model_class [Class]
47
+ # @return [Array<String>]
48
+ def editable_attributes(model_class)
49
+ base_attributes = model_class.attribute_names.reject do |attr|
50
+ EDIT_EXCLUDED_ATTRIBUTES.include?(attr) || SENSITIVE_ATTRIBUTES.include?(attr)
51
+ end
52
+
53
+ nested_attributes = if model_class.respond_to?(:nested_attributes_options)
54
+ model_class.nested_attributes_options.keys.map { |name| "#{name}_attributes" }
55
+ else
56
+ []
57
+ end
58
+
59
+ (base_attributes + nested_attributes).uniq
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+ require "stringio"
5
+
6
+ module SuperAdmin
7
+ # Generates a CSV export for a SuperAdmin resource respecting visible attributes.
8
+ class ResourceExporter
9
+ BATCH_SIZE = 1000
10
+
11
+ def initialize(model_class, scope, attributes: nil)
12
+ @model_class = model_class
13
+ @scope = scope
14
+ @attributes = attributes.presence || model_class.attribute_names
15
+ end
16
+
17
+ # Writes CSV content to a given IO (Tempfile, StringIO, etc.).
18
+ # @param io [IO]
19
+ def write_to(io)
20
+ csv = CSV.new(io, headers: header_row, write_headers: true)
21
+ export_scope.find_in_batches(batch_size: BATCH_SIZE) do |batch|
22
+ batch.each do |record|
23
+ csv << data_row(record)
24
+ end
25
+ io.flush if io.respond_to?(:flush)
26
+ end
27
+ end
28
+
29
+ # @return [String] contenu CSV avec en-têtes (utilisé principalement pour tests)
30
+ def to_csv
31
+ buffer = StringIO.new
32
+ write_to(buffer)
33
+ buffer.rewind
34
+ buffer.read
35
+ ensure
36
+ buffer&.close
37
+ end
38
+
39
+ private
40
+
41
+ attr_reader :model_class, :scope, :attributes
42
+
43
+ def header_row
44
+ attributes.map { |attr| model_class.human_attribute_name(attr) }
45
+ end
46
+
47
+ def data_row(record)
48
+ attributes.map do |attr|
49
+ value = record.public_send(attr)
50
+ format_value(value)
51
+ end
52
+ end
53
+
54
+ def export_scope
55
+ relation = scope
56
+ if relation.respond_to?(:except)
57
+ relation = relation.except(:select, :includes, :preload, :eager_load)
58
+ end
59
+ relation = relation.reorder(nil) if relation.order_values.any?
60
+ relation = relation.limit(nil) if relation.respond_to?(:limit)
61
+ relation = relation.offset(nil) if relation.respond_to?(:offset)
62
+ relation
63
+ end
64
+
65
+ def format_value(value)
66
+ case value
67
+ when nil
68
+ ""
69
+ when true, false
70
+ value ? "true" : "false"
71
+ when Time, Date, DateTime, ActiveSupport::TimeWithZone
72
+ value.iso8601
73
+ else
74
+ value.to_s
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SuperAdmin
4
+ # Builds ActiveRecord scopes for SuperAdmin resources (search, filters, sort).
5
+ # Delegates logic to query objects for better separation of concerns.
6
+ class ResourceQuery
7
+ class << self
8
+ # Returns the filtered scope ready for pagination/export.
9
+ # @param model_class [Class]
10
+ # @param search [String, nil]
11
+ # @param sort [String, nil]
12
+ # @param direction [String, nil]
13
+ # @param filters [Hash]
14
+ def filtered_scope(model_class, search:, sort:, direction:, filters: {})
15
+ SuperAdmin::Queries::ResourceScopeQuery.new(
16
+ model_class,
17
+ search: search,
18
+ sort_column: sort,
19
+ direction: direction,
20
+ filters: filters
21
+ ).call
22
+ end
23
+
24
+ # DEPRECATED: Use SuperAdmin::Queries::SearchQuery directly
25
+ def apply_search(scope, model_class, term)
26
+ SuperAdmin::Queries::SearchQuery.new(scope, model_class, term: term).call
27
+ end
28
+
29
+ # DEPRECATED: Use SuperAdmin::Queries::SortQuery directly
30
+ def apply_sort(scope, model_class, sort, direction)
31
+ SuperAdmin::Queries::SortQuery.new(scope, model_class, sort_column: sort, direction: direction).call
32
+ end
33
+
34
+ # DEPRECATED: Method kept for compatibility but not used
35
+ def association_search_predicates(model_class, patterns:, lower_pattern:)
36
+ { predicates: [], associations: [] }
37
+ end
38
+ end
39
+ end
40
+ end