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,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ostruct"
|
|
4
|
+
|
|
5
|
+
module SuperAdmin
|
|
6
|
+
# Base controller for all SuperAdmin controllers.
|
|
7
|
+
# Handles authentication, authorization, and custom layout.
|
|
8
|
+
class BaseController < SuperAdmin::ApplicationController
|
|
9
|
+
before_action :authorize_super_admin!
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
# Pagy helper method for pagination
|
|
14
|
+
# Returns a simple pagination object and the paginated collection
|
|
15
|
+
def pagy(collection, **vars)
|
|
16
|
+
# Set default items per page
|
|
17
|
+
items_per_page = vars.delete(:limit) || 25
|
|
18
|
+
|
|
19
|
+
# Get current page from params
|
|
20
|
+
page = (params[:page] || 1).to_i
|
|
21
|
+
page = 1 if page < 1
|
|
22
|
+
|
|
23
|
+
# Calculate total count
|
|
24
|
+
count = collection.count
|
|
25
|
+
|
|
26
|
+
# Calculate pagination values
|
|
27
|
+
pages = (count.to_f / items_per_page).ceil
|
|
28
|
+
pages = 1 if pages < 1
|
|
29
|
+
page = pages if page > pages
|
|
30
|
+
|
|
31
|
+
offset = (page - 1) * items_per_page
|
|
32
|
+
|
|
33
|
+
# Create a simple pagination object
|
|
34
|
+
pagy_obj = OpenStruct.new(
|
|
35
|
+
count: count,
|
|
36
|
+
page: page,
|
|
37
|
+
items: items_per_page,
|
|
38
|
+
pages: pages,
|
|
39
|
+
offset: offset,
|
|
40
|
+
limit: items_per_page,
|
|
41
|
+
next: (page < pages ? page + 1 : nil),
|
|
42
|
+
prev: (page > 1 ? page - 1 : nil),
|
|
43
|
+
in: [ [ (offset + 1), count ].min, [ offset + items_per_page, count ].min ]
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
# Return paginated collection
|
|
47
|
+
[ pagy_obj, collection.offset(offset).limit(items_per_page) ]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def authorize_super_admin!
|
|
51
|
+
return if SuperAdmin::Authorization.call(self)
|
|
52
|
+
|
|
53
|
+
# Si call retourne false, l'adapter a déjà géré la réponse (redirect/render).
|
|
54
|
+
# On stoppe la suite du traitement via la primitive Rack disponible.
|
|
55
|
+
if request.respond_to?(:halt)
|
|
56
|
+
request.halt
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
false
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def available_models
|
|
63
|
+
@available_models ||= SuperAdmin::ModelInspector.all_models
|
|
64
|
+
end
|
|
65
|
+
helper_method :available_models
|
|
66
|
+
|
|
67
|
+
def model_display_name(model_class)
|
|
68
|
+
model_class.model_name.human(count: 2)
|
|
69
|
+
end
|
|
70
|
+
helper_method :model_display_name
|
|
71
|
+
|
|
72
|
+
def model_path(model_class)
|
|
73
|
+
SuperAdmin::Engine.routes.url_helpers.resources_path(resource: model_class.name.underscore.pluralize)
|
|
74
|
+
end
|
|
75
|
+
helper_method :model_path
|
|
76
|
+
|
|
77
|
+
# Helper methods for routes that work in both standalone and mounted engine contexts
|
|
78
|
+
def super_admin_root_path
|
|
79
|
+
SuperAdmin::Engine.routes.url_helpers.root_path
|
|
80
|
+
end
|
|
81
|
+
helper_method :super_admin_root_path
|
|
82
|
+
|
|
83
|
+
def super_admin_exports_path
|
|
84
|
+
SuperAdmin::Engine.routes.url_helpers.exports_path
|
|
85
|
+
end
|
|
86
|
+
helper_method :super_admin_exports_path
|
|
87
|
+
|
|
88
|
+
def super_admin_audit_logs_path
|
|
89
|
+
SuperAdmin::Engine.routes.url_helpers.audit_logs_path
|
|
90
|
+
end
|
|
91
|
+
helper_method :super_admin_audit_logs_path
|
|
92
|
+
|
|
93
|
+
def super_admin_export_path(token)
|
|
94
|
+
SuperAdmin::Engine.routes.url_helpers.export_path(token)
|
|
95
|
+
end
|
|
96
|
+
helper_method :super_admin_export_path
|
|
97
|
+
|
|
98
|
+
def download_super_admin_export_path(token)
|
|
99
|
+
SuperAdmin::Engine.routes.url_helpers.download_export_path(token)
|
|
100
|
+
end
|
|
101
|
+
helper_method :download_super_admin_export_path
|
|
102
|
+
|
|
103
|
+
def super_admin_resources_path(options = {})
|
|
104
|
+
SuperAdmin::Engine.routes.url_helpers.resources_path(options)
|
|
105
|
+
end
|
|
106
|
+
helper_method :super_admin_resources_path
|
|
107
|
+
|
|
108
|
+
def super_admin_resource_path(options = {})
|
|
109
|
+
SuperAdmin::Engine.routes.url_helpers.resource_path(options)
|
|
110
|
+
end
|
|
111
|
+
helper_method :super_admin_resource_path
|
|
112
|
+
|
|
113
|
+
def super_admin_new_resource_path(options = {})
|
|
114
|
+
SuperAdmin::Engine.routes.url_helpers.new_resource_path(options)
|
|
115
|
+
end
|
|
116
|
+
helper_method :super_admin_new_resource_path
|
|
117
|
+
|
|
118
|
+
def super_admin_edit_resource_path(options = {})
|
|
119
|
+
SuperAdmin::Engine.routes.url_helpers.edit_resource_path(options)
|
|
120
|
+
end
|
|
121
|
+
helper_method :super_admin_edit_resource_path
|
|
122
|
+
|
|
123
|
+
def super_admin_bulk_action_path(options = {})
|
|
124
|
+
SuperAdmin::Engine.routes.url_helpers.bulk_action_path(options)
|
|
125
|
+
end
|
|
126
|
+
helper_method :super_admin_bulk_action_path
|
|
127
|
+
|
|
128
|
+
def super_admin_association_search_path(options = {})
|
|
129
|
+
SuperAdmin::Engine.routes.url_helpers.association_search_path(options)
|
|
130
|
+
end
|
|
131
|
+
helper_method :super_admin_association_search_path
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SuperAdmin
|
|
4
|
+
# SuperAdmin dashboard controller displaying all administrable models.
|
|
5
|
+
class DashboardController < SuperAdmin::BaseController
|
|
6
|
+
def index
|
|
7
|
+
@models_info = available_models.map do |model_class|
|
|
8
|
+
begin
|
|
9
|
+
count = model_class.count
|
|
10
|
+
rescue ActiveRecord::StatementInvalid, StandardError => e
|
|
11
|
+
Rails.logger.warn("Cannot count #{model_class.name}: #{e.message}")
|
|
12
|
+
count = 0
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
{
|
|
16
|
+
class: model_class,
|
|
17
|
+
name: model_class.name,
|
|
18
|
+
human_name: model_display_name(model_class),
|
|
19
|
+
count: count,
|
|
20
|
+
table_name: model_class.table_name,
|
|
21
|
+
path: model_path(model_class)
|
|
22
|
+
}
|
|
23
|
+
rescue StandardError => e
|
|
24
|
+
Rails.logger.error("Error inspecting model #{model_class.name}: #{e.message}")
|
|
25
|
+
nil
|
|
26
|
+
end.compact
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SuperAdmin
|
|
4
|
+
# Handles creation and tracking of SuperAdmin CSV exports.
|
|
5
|
+
class ExportsController < SuperAdmin::BaseController
|
|
6
|
+
helper SuperAdmin::ExportsHelper
|
|
7
|
+
|
|
8
|
+
before_action :load_model_class, only: :create
|
|
9
|
+
before_action :find_export, only: %i[show download destroy]
|
|
10
|
+
|
|
11
|
+
def index
|
|
12
|
+
exports_scope = current_user.csv_exports.recent_first
|
|
13
|
+
exports_scope = exports_scope.includes(:file_attachment) if active_storage_available?
|
|
14
|
+
@pagy, @exports = pagy(exports_scope, limit: 20)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def show
|
|
18
|
+
nil if performed?
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def create
|
|
22
|
+
return if performed?
|
|
23
|
+
|
|
24
|
+
filters = sanitized_filters(@model_class, params[:filters])
|
|
25
|
+
|
|
26
|
+
export = SuperAdmin::CsvExportCreator.call(
|
|
27
|
+
user: current_user,
|
|
28
|
+
model_class: @model_class,
|
|
29
|
+
resource: params[:resource],
|
|
30
|
+
search: params[:search],
|
|
31
|
+
sort: params[:sort],
|
|
32
|
+
direction: params[:direction],
|
|
33
|
+
filters: filters,
|
|
34
|
+
attributes: SuperAdmin::DashboardResolver.collection_attributes_for(@model_class)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
redirect_to super_admin_exports_path,
|
|
38
|
+
notice: t("super_admin.exports.flash.created", token: export.token)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def download
|
|
42
|
+
return unless @export
|
|
43
|
+
unless @export.ready_for_download?
|
|
44
|
+
redirect_to super_admin_exports_path,
|
|
45
|
+
alert: t("super_admin.exports.flash.unavailable")
|
|
46
|
+
return
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
if @export.expires_at.present? && @export.expires_at.past?
|
|
50
|
+
redirect_to super_admin_exports_path,
|
|
51
|
+
alert: t("super_admin.exports.flash.expired")
|
|
52
|
+
return
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
send_data @export.file.download,
|
|
56
|
+
filename: @export.file.filename.to_s,
|
|
57
|
+
type: @export.file.content_type || "text/csv",
|
|
58
|
+
disposition: "attachment"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def destroy
|
|
62
|
+
return unless @export
|
|
63
|
+
|
|
64
|
+
if active_storage_available?
|
|
65
|
+
@export.destroy!
|
|
66
|
+
else
|
|
67
|
+
@export.delete
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
redirect_to super_admin_exports_path,
|
|
71
|
+
notice: t("super_admin.exports.flash.destroyed")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def load_model_class
|
|
77
|
+
@model_class = SuperAdmin::ModelInspector.find_model(params[:resource])
|
|
78
|
+
|
|
79
|
+
return if @model_class
|
|
80
|
+
|
|
81
|
+
redirect_to super_admin_root_path,
|
|
82
|
+
alert: t("super_admin.resources.flash.load_model_failed", resource: params[:resource])
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def sanitized_filters(model_class, raw_filters)
|
|
86
|
+
return {} if raw_filters.blank?
|
|
87
|
+
|
|
88
|
+
filters_params = raw_filters.is_a?(ActionController::Parameters) ? raw_filters : ActionController::Parameters.new(raw_filters)
|
|
89
|
+
permitted_keys = SuperAdmin::FilterBuilder.permitted_param_keys(model_class)
|
|
90
|
+
filters_params.permit(*permitted_keys).to_unsafe_h
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def find_export
|
|
94
|
+
@export = current_user.csv_exports.find_by(token: params[:token])
|
|
95
|
+
return if @export
|
|
96
|
+
|
|
97
|
+
redirect_to super_admin_exports_path,
|
|
98
|
+
alert: t("super_admin.exports.flash.not_found")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def active_storage_available?
|
|
102
|
+
return false unless defined?(ActiveStorage::Attachment)
|
|
103
|
+
|
|
104
|
+
ActiveStorage::Attachment.table_exists?
|
|
105
|
+
rescue ActiveRecord::StatementInvalid, ActiveRecord::NoDatabaseError
|
|
106
|
+
false
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SuperAdmin
|
|
4
|
+
# Generic CRUD controller for all resources.
|
|
5
|
+
# Uses ActiveRecord reflection to adapt dynamically to each model.
|
|
6
|
+
class ResourcesController < SuperAdmin::BaseController
|
|
7
|
+
before_action :load_resource_context
|
|
8
|
+
before_action :set_resource, only: %i[show edit update destroy]
|
|
9
|
+
|
|
10
|
+
# GET /super_admin/:resource
|
|
11
|
+
def index
|
|
12
|
+
collection = SuperAdmin::Resources::CollectionPresenter.new(context: @context, params: params)
|
|
13
|
+
@filter_definitions = collection.filter_definitions
|
|
14
|
+
@attributes = @context.displayable_attributes
|
|
15
|
+
|
|
16
|
+
respond_to do |format|
|
|
17
|
+
format.html do
|
|
18
|
+
@applied_filters = collection.filter_params
|
|
19
|
+
@pagy, @resources = pagy(collection.scope, limit: 25)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
format.csv do
|
|
23
|
+
export = collection.queue_export!(current_user, @attributes)
|
|
24
|
+
|
|
25
|
+
flash[:notice] = t("super_admin.exports.flash.created", token: export.token)
|
|
26
|
+
redirect_to super_admin_exports_path
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# GET /super_admin/:resource/:id
|
|
32
|
+
def show
|
|
33
|
+
@attributes = @context.show_attributes
|
|
34
|
+
@associations = @model_class.reflect_on_all_associations
|
|
35
|
+
@association_counts = SuperAdmin::Resources::AssociationInspector.new(@resource).has_many_counts(@associations)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# GET /super_admin/:resource/new
|
|
39
|
+
def new
|
|
40
|
+
@resource = @model_class.new
|
|
41
|
+
@editable_attributes = @context.editable_attributes
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# POST /super_admin/:resource
|
|
45
|
+
def create
|
|
46
|
+
@resource = @model_class.new(resource_params)
|
|
47
|
+
|
|
48
|
+
if @resource.save
|
|
49
|
+
audit(:create, resource: @resource)
|
|
50
|
+
redirect_to super_admin_resource_path(resource: params[:resource], id: @resource.id),
|
|
51
|
+
notice: t("super_admin.resources.flash.create.success", model: @model_class.model_name.human)
|
|
52
|
+
else
|
|
53
|
+
@editable_attributes = @context.editable_attributes
|
|
54
|
+
render :new, status: :unprocessable_entity
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# GET /super_admin/:resource/:id/edit
|
|
59
|
+
def edit
|
|
60
|
+
@editable_attributes = @context.editable_attributes
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# PATCH /super_admin/:resource/:id
|
|
64
|
+
def update
|
|
65
|
+
if @resource.update(resource_params)
|
|
66
|
+
audit(:update, resource: @resource, changes: @resource.previous_changes)
|
|
67
|
+
redirect_to super_admin_resource_path(resource: params[:resource], id: @resource.id),
|
|
68
|
+
notice: t("super_admin.resources.flash.update.success", model: @model_class.model_name.human)
|
|
69
|
+
else
|
|
70
|
+
@editable_attributes = @context.editable_attributes
|
|
71
|
+
render :edit, status: :unprocessable_entity
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# DELETE /super_admin/:resource/:id
|
|
76
|
+
def destroy
|
|
77
|
+
snapshot = @resource.attributes
|
|
78
|
+
@resource.destroy!
|
|
79
|
+
audit(
|
|
80
|
+
:destroy,
|
|
81
|
+
resource_type: @model_class.name,
|
|
82
|
+
resource_id: snapshot[@model_class.primary_key]&.to_s,
|
|
83
|
+
changes: { "before" => snapshot }
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
redirect_to super_admin_resources_path(resource: params[:resource]),
|
|
87
|
+
notice: t("super_admin.resources.flash.destroy.success", model: @model_class.model_name.human)
|
|
88
|
+
rescue ActiveRecord::InvalidForeignKey, ActiveRecord::DeleteRestrictionError
|
|
89
|
+
redirect_to super_admin_resource_path(resource: params[:resource], id: @resource.id),
|
|
90
|
+
alert: t("super_admin.resources.flash.destroy.dependencies")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# POST /super_admin/:resource/bulk
|
|
94
|
+
def bulk
|
|
95
|
+
collection = SuperAdmin::Resources::CollectionPresenter.new(context: @context, params: params)
|
|
96
|
+
selection = bulk_params[:resource_ids]&.reject(&:blank?) || []
|
|
97
|
+
action = bulk_params[:bulk_action]
|
|
98
|
+
|
|
99
|
+
if selection.empty?
|
|
100
|
+
redirect_to super_admin_resources_path(resource: params[:resource], **collection.preserved_params),
|
|
101
|
+
alert: t("super_admin.resources.flash.bulk.selection_required")
|
|
102
|
+
return
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
case action
|
|
106
|
+
when "destroy"
|
|
107
|
+
records = @model_class.where(id: selection)
|
|
108
|
+
destroyed_count = records.size
|
|
109
|
+
@model_class.transaction do
|
|
110
|
+
records.find_each do |record|
|
|
111
|
+
snapshot = record.attributes
|
|
112
|
+
record.destroy!
|
|
113
|
+
audit(
|
|
114
|
+
:destroy,
|
|
115
|
+
resource_type: @model_class.name,
|
|
116
|
+
resource_id: snapshot[@model_class.primary_key]&.to_s,
|
|
117
|
+
changes: { "before" => snapshot, "bulk" => true, "selection" => selection }
|
|
118
|
+
)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
redirect_to super_admin_resources_path(resource: params[:resource], **collection.preserved_params),
|
|
122
|
+
notice: t("super_admin.resources.flash.bulk.success", count: destroyed_count, model: @model_class.model_name.human(count: destroyed_count))
|
|
123
|
+
else
|
|
124
|
+
redirect_to super_admin_resources_path(resource: params[:resource], **collection.preserved_params),
|
|
125
|
+
alert: t("super_admin.resources.flash.bulk.unsupported_action")
|
|
126
|
+
end
|
|
127
|
+
rescue ActiveRecord::InvalidForeignKey, ActiveRecord::DeleteRestrictionError
|
|
128
|
+
redirect_to super_admin_resources_path(resource: params[:resource], **collection.preserved_params),
|
|
129
|
+
alert: t("super_admin.resources.flash.bulk.dependencies")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
def load_resource_context
|
|
135
|
+
@context = SuperAdmin::Resources::Context.new(params[:resource])
|
|
136
|
+
@model_class = @context.model_class
|
|
137
|
+
|
|
138
|
+
return if @context.valid?
|
|
139
|
+
|
|
140
|
+
flash[:alert] = t("super_admin.resources.flash.load_model_failed", resource: params[:resource])
|
|
141
|
+
redirect_to super_admin_root_path
|
|
142
|
+
request.halt if request.respond_to?(:halt)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def set_resource
|
|
146
|
+
relation = @model_class
|
|
147
|
+
if action_name == "show"
|
|
148
|
+
# Use dashboard-configured includes or fall back to AssociationInspector
|
|
149
|
+
includes = SuperAdmin::DashboardResolver.show_includes_for(@model_class)
|
|
150
|
+
if includes.empty?
|
|
151
|
+
includes = SuperAdmin::Resources::AssociationInspector.preloadable_names(@model_class)
|
|
152
|
+
end
|
|
153
|
+
relation = relation.includes(includes) if includes.any?
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
@resource = relation.find(params[:id])
|
|
157
|
+
rescue ActiveRecord::RecordNotFound
|
|
158
|
+
flash[:alert] = t("super_admin.resources.flash.not_found", model: @model_class.model_name.human)
|
|
159
|
+
redirect_to super_admin_resources_path(resource: params[:resource])
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def bulk_params
|
|
163
|
+
params.permit(:bulk_action, resource_ids: [])
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Dynamic strong parameters based on model attributes and nested attributes
|
|
167
|
+
def resource_params
|
|
168
|
+
permitted = SuperAdmin::Resources::PermittedAttributes.new(@model_class).permit(params)
|
|
169
|
+
SuperAdmin::Resources::ValueNormalizer.new(@model_class, permitted).normalize
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def audit(action, resource: nil, resource_type: nil, resource_id: nil, changes: nil)
|
|
173
|
+
return unless auditable?
|
|
174
|
+
|
|
175
|
+
SuperAdmin::Auditing.log!(
|
|
176
|
+
user: current_user,
|
|
177
|
+
resource: resource,
|
|
178
|
+
resource_type: resource_type,
|
|
179
|
+
resource_id: resource_id,
|
|
180
|
+
action: action,
|
|
181
|
+
changes: changes,
|
|
182
|
+
context: audit_context
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def auditable?
|
|
187
|
+
@model_class.present? && @model_class.name != "SuperAdmin::AuditLog"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def audit_context
|
|
191
|
+
{
|
|
192
|
+
"resource" => params[:resource],
|
|
193
|
+
"resource_id" => params[:id],
|
|
194
|
+
"request_path" => request.fullpath,
|
|
195
|
+
"request_id" => request.request_id,
|
|
196
|
+
"controller" => controller_name,
|
|
197
|
+
"action" => action_name
|
|
198
|
+
}.compact
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SuperAdmin
|
|
4
|
+
# Base DSL to configure how resources are displayed inside SuperAdmin.
|
|
5
|
+
# Inspired by Administrate dashboards but focused on selecting visible attributes.
|
|
6
|
+
class BaseDashboard
|
|
7
|
+
class_attribute :_resource_class, instance_writer: false
|
|
8
|
+
class_attribute :_collection_attributes, default: nil, instance_writer: false
|
|
9
|
+
class_attribute :_show_attributes, default: nil, instance_writer: false
|
|
10
|
+
class_attribute :_form_attributes, default: nil, instance_writer: false
|
|
11
|
+
class_attribute :_collection_includes, default: nil, instance_writer: false
|
|
12
|
+
class_attribute :_show_includes, default: nil, instance_writer: false
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
# Explicitly sets the resource class managed by this dashboard.
|
|
16
|
+
# When omitted, the class is inferred from the dashboard name (e.g. UserDashboard => User).
|
|
17
|
+
def resource(model_class)
|
|
18
|
+
self._resource_class = model_class
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Defines attributes shown on the collection (index) page.
|
|
22
|
+
# Usage: collection_attributes :id, :name, :status
|
|
23
|
+
def collection_attributes(*attrs)
|
|
24
|
+
self._collection_attributes = normalize_flat_attribute_list(attrs)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Defines attributes shown on the resource detail (show) page.
|
|
28
|
+
def show_attributes(*attrs)
|
|
29
|
+
self._show_attributes = normalize_flat_attribute_list(attrs)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Defines attributes shown in resource forms (new/edit).
|
|
33
|
+
def form_attributes(*attrs)
|
|
34
|
+
self._form_attributes = normalize_flat_attribute_list(attrs)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Defines associations to preload for the collection (index) page to avoid N+1 queries.
|
|
38
|
+
# Usage: collection_includes :author, :tags, comments: :user
|
|
39
|
+
def collection_includes(*associations)
|
|
40
|
+
self._collection_includes = associations.freeze
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Defines associations to preload for the show page to avoid N+1 queries.
|
|
44
|
+
# Usage: show_includes :author, :tags, comments: :user
|
|
45
|
+
def show_includes(*associations)
|
|
46
|
+
self._show_includes = associations.freeze
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Returns the ActiveRecord class associated with the dashboard.
|
|
50
|
+
def resource_class
|
|
51
|
+
resolved_resource_class || infer_resource_class
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Returns attributes configured for a given view.
|
|
55
|
+
def attributes_for(view)
|
|
56
|
+
case view.to_sym
|
|
57
|
+
when :index, :collection, :list
|
|
58
|
+
collection_attributes_list
|
|
59
|
+
when :show, :detail
|
|
60
|
+
show_attributes_list
|
|
61
|
+
when :form, :new, :edit
|
|
62
|
+
form_attributes_list
|
|
63
|
+
else
|
|
64
|
+
[]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Returns configured collection attributes or sensible defaults.
|
|
69
|
+
def collection_attributes_list
|
|
70
|
+
_collection_attributes || default_collection_attributes
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Returns configured show attributes or sensible defaults.
|
|
74
|
+
def show_attributes_list
|
|
75
|
+
_show_attributes || default_show_attributes
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Returns configured form attributes or sensible defaults.
|
|
79
|
+
def form_attributes_list
|
|
80
|
+
_form_attributes || default_form_attributes
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Returns associations to preload for collection view
|
|
84
|
+
def collection_includes_list
|
|
85
|
+
_collection_includes || default_collection_includes
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Returns associations to preload for show view
|
|
89
|
+
def show_includes_list
|
|
90
|
+
_show_includes || default_show_includes
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def resolved_resource_class
|
|
96
|
+
case _resource_class
|
|
97
|
+
when Class
|
|
98
|
+
_resource_class
|
|
99
|
+
when String, Symbol
|
|
100
|
+
_resource_class.to_s.constantize
|
|
101
|
+
else
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
rescue NameError
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def normalize_attribute_list(attrs)
|
|
109
|
+
Array(attrs).flatten.compact.map do |attribute|
|
|
110
|
+
normalize_attribute_entry(attribute)
|
|
111
|
+
end.freeze
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def normalize_flat_attribute_list(attrs)
|
|
115
|
+
normalize_attribute_list(attrs).flat_map do |entry|
|
|
116
|
+
entry.is_a?(Hash) ? entry.keys.map(&:to_sym) : entry
|
|
117
|
+
end.map(&:to_sym).freeze
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def normalize_attribute_entry(attribute)
|
|
121
|
+
case attribute
|
|
122
|
+
when Hash
|
|
123
|
+
attribute.each_with_object({}) do |(key, value), hash|
|
|
124
|
+
hash[key.to_sym] = Array(value).flatten.compact.map { |entry| normalize_attribute_entry(entry) }
|
|
125
|
+
end
|
|
126
|
+
else
|
|
127
|
+
attribute.to_sym
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def infer_resource_class
|
|
132
|
+
name.delete_suffix("Dashboard").constantize
|
|
133
|
+
rescue NameError
|
|
134
|
+
nil
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def default_collection_attributes
|
|
138
|
+
default_displayable_attributes
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def default_show_attributes
|
|
142
|
+
default_displayable_attributes
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def default_form_attributes
|
|
146
|
+
resource = resource_class
|
|
147
|
+
return [] unless resource
|
|
148
|
+
|
|
149
|
+
editable = SuperAdmin::ResourceConfiguration.editable_attributes(resource)
|
|
150
|
+
editable.map { |attr| attr.to_sym }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def default_displayable_attributes
|
|
154
|
+
resource = resource_class
|
|
155
|
+
return [] unless resource
|
|
156
|
+
|
|
157
|
+
attrs = SuperAdmin::ResourceConfiguration.displayable_attributes(resource)
|
|
158
|
+
attrs.map { |attr| attr.to_sym }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def default_collection_includes
|
|
162
|
+
resource = resource_class
|
|
163
|
+
return [] unless resource
|
|
164
|
+
|
|
165
|
+
# Auto-detect preloadable associations explicitly listed in the collection attributes
|
|
166
|
+
associations_from_attributes(collection_attributes_list, resource)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def default_show_includes
|
|
170
|
+
resource = resource_class
|
|
171
|
+
return [] unless resource
|
|
172
|
+
|
|
173
|
+
# Auto-detect belongs_to and has_one associations which benefit from eager loading
|
|
174
|
+
preloadable_association_names(resource)
|
|
175
|
+
rescue StandardError
|
|
176
|
+
[]
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def associations_from_attributes(attributes, resource)
|
|
180
|
+
return [] unless resource.respond_to?(:reflect_on_all_associations)
|
|
181
|
+
|
|
182
|
+
preloadable_associations = preloadable_association_names(resource)
|
|
183
|
+
|
|
184
|
+
# Only preload associations explicitly referenced in the attribute list.
|
|
185
|
+
attributes.select do |attr|
|
|
186
|
+
preloadable_associations.include?(attr)
|
|
187
|
+
end.uniq
|
|
188
|
+
rescue StandardError
|
|
189
|
+
[]
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def preloadable_association_names(resource)
|
|
193
|
+
resource.reflect_on_all_associations
|
|
194
|
+
.select { |reflection| %i[belongs_to has_one].include?(reflection.macro) }
|
|
195
|
+
.reject(&:polymorphic?)
|
|
196
|
+
.map(&:name)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|