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.
- checksums.yaml +7 -0
- data/README.md +216 -0
- data/Rakefile +30 -0
- data/app/assets/stylesheets/super_admin/application.css +15 -0
- data/app/assets/stylesheets/super_admin/tailwind.css +1 -0
- data/app/assets/stylesheets/super_admin/tailwind.source.css +25 -0
- data/app/controllers/super_admin/application_controller.rb +89 -0
- data/app/controllers/super_admin/associations_controller.rb +136 -0
- data/app/controllers/super_admin/audit_logs_controller.rb +39 -0
- data/app/controllers/super_admin/base_controller.rb +133 -0
- data/app/controllers/super_admin/dashboard_controller.rb +29 -0
- data/app/controllers/super_admin/exports_controller.rb +109 -0
- data/app/controllers/super_admin/resources_controller.rb +201 -0
- data/app/dashboards/super_admin/base_dashboard.rb +200 -0
- data/app/errors/super_admin/configuration_error.rb +6 -0
- data/app/helpers/super_admin/application_helper.rb +84 -0
- data/app/helpers/super_admin/exports_helper.rb +16 -0
- data/app/helpers/super_admin/resources_helper.rb +204 -0
- data/app/helpers/super_admin/route_helper.rb +7 -0
- data/app/javascript/super_admin/application.js +263 -0
- data/app/jobs/super_admin/application_job.rb +4 -0
- data/app/jobs/super_admin/generate_super_admin_csv_export_job.rb +100 -0
- data/app/mailers/super_admin/application_mailer.rb +6 -0
- data/app/models/super_admin/application_record.rb +5 -0
- data/app/models/super_admin/audit_log.rb +35 -0
- data/app/models/super_admin/csv_export.rb +67 -0
- data/app/services/super_admin/auditing.rb +74 -0
- data/app/services/super_admin/authorization.rb +113 -0
- data/app/services/super_admin/authorization_adapters/base_adapter.rb +100 -0
- data/app/services/super_admin/authorization_adapters/default_adapter.rb +77 -0
- data/app/services/super_admin/authorization_adapters/proc_adapter.rb +65 -0
- data/app/services/super_admin/authorization_adapters/pundit_adapter.rb +81 -0
- data/app/services/super_admin/csv_export_creator.rb +45 -0
- data/app/services/super_admin/dashboard_registry.rb +90 -0
- data/app/services/super_admin/dashboard_resolver.rb +100 -0
- data/app/services/super_admin/filter_builder.rb +185 -0
- data/app/services/super_admin/form_builder.rb +59 -0
- data/app/services/super_admin/form_fields/array_field.rb +35 -0
- data/app/services/super_admin/form_fields/association_field.rb +146 -0
- data/app/services/super_admin/form_fields/base_field.rb +53 -0
- data/app/services/super_admin/form_fields/boolean_field.rb +29 -0
- data/app/services/super_admin/form_fields/date_field.rb +15 -0
- data/app/services/super_admin/form_fields/date_time_field.rb +15 -0
- data/app/services/super_admin/form_fields/enum_field.rb +27 -0
- data/app/services/super_admin/form_fields/factory.rb +102 -0
- data/app/services/super_admin/form_fields/nested_field.rb +120 -0
- data/app/services/super_admin/form_fields/number_field.rb +29 -0
- data/app/services/super_admin/form_fields/text_area_field.rb +19 -0
- data/app/services/super_admin/model_inspector.rb +182 -0
- data/app/services/super_admin/queries/base_query.rb +45 -0
- data/app/services/super_admin/queries/filter_query.rb +188 -0
- data/app/services/super_admin/queries/resource_scope_query.rb +74 -0
- data/app/services/super_admin/queries/search_query.rb +146 -0
- data/app/services/super_admin/queries/sort_query.rb +41 -0
- data/app/services/super_admin/resource_configuration.rb +63 -0
- data/app/services/super_admin/resource_exporter.rb +78 -0
- data/app/services/super_admin/resource_query.rb +40 -0
- data/app/services/super_admin/resources/association_inspector.rb +112 -0
- data/app/services/super_admin/resources/collection_presenter.rb +63 -0
- data/app/services/super_admin/resources/context.rb +63 -0
- data/app/services/super_admin/resources/filter_params.rb +29 -0
- data/app/services/super_admin/resources/permitted_attributes.rb +104 -0
- data/app/services/super_admin/resources/value_normalizer.rb +121 -0
- data/app/services/super_admin/sensitive_attributes.rb +166 -0
- data/app/views/layouts/super_admin.html.erb +74 -0
- data/app/views/super_admin/audit_logs/index.html.erb +143 -0
- data/app/views/super_admin/dashboard/index.html.erb +79 -0
- data/app/views/super_admin/exports/index.html.erb +84 -0
- data/app/views/super_admin/exports/show.html.erb +57 -0
- data/app/views/super_admin/resources/_form.html.erb +42 -0
- data/app/views/super_admin/resources/destroy.turbo_stream.erb +17 -0
- data/app/views/super_admin/resources/edit.html.erb +37 -0
- data/app/views/super_admin/resources/index.html.erb +189 -0
- data/app/views/super_admin/resources/new.html.erb +31 -0
- data/app/views/super_admin/resources/show.html.erb +106 -0
- data/app/views/super_admin/shared/_breadcrumbs.html.erb +12 -0
- data/app/views/super_admin/shared/_custom_styles.html.erb +132 -0
- data/app/views/super_admin/shared/_flash.html.erb +55 -0
- data/app/views/super_admin/shared/_form_field.html.erb +35 -0
- data/app/views/super_admin/shared/_navigation.html.erb +92 -0
- data/app/views/super_admin/shared/_nested_fields.html.erb +59 -0
- data/app/views/super_admin/shared/_nested_record_fields.html.erb +45 -0
- data/config/importmap.rb +4 -0
- data/config/initializers/rack_attack.rb +134 -0
- data/config/initializers/super_admin.rb +117 -0
- data/config/locales/super_admin.en.yml +197 -0
- data/config/locales/super_admin.fr.yml +197 -0
- data/config/routes.rb +22 -0
- data/lib/generators/super_admin/dashboard_generator.rb +50 -0
- data/lib/generators/super_admin/install_generator.rb +58 -0
- data/lib/generators/super_admin/templates/20240101000001_create_super_admin_audit_logs.rb +24 -0
- data/lib/generators/super_admin/templates/20240101000002_create_super_admin_csv_exports.rb +33 -0
- data/lib/generators/super_admin/templates/super_admin.rb +58 -0
- data/lib/super_admin/dashboard_creator.rb +256 -0
- data/lib/super_admin/engine.rb +53 -0
- data/lib/super_admin/install_task.rb +96 -0
- data/lib/super_admin/version.rb +3 -0
- data/lib/super_admin.rb +7 -0
- data/lib/tasks/super_admin_tasks.rake +38 -0
- 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
|