From ae2be237fb6e2ec5c2ed315ebc6e92ace60027f7 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 8 Apr 2026 09:41:19 +0200 Subject: [PATCH 1/2] Add active_admin_import_context convention for controller-provided context Controllers can define an `active_admin_import_context` method returning a hash; it is merged into the import model after form params so values the controller is authoritative about (parent.id, current_user.id, request attributes, etc.) cannot be overridden by tampered form fields. This supersedes PR #137 by offering a general-purpose hook that works for nested belongs_to imports and any other controller-derived state. --- lib/active_admin_import/dsl.rb | 28 +++++++----- spec/fixtures/files/post_comments.csv | 3 ++ spec/import_spec.rb | 63 +++++++++++++++++++++++++++ spec/support/admin.rb | 16 +++++++ spec/support/rails_template.rb | 5 ++- 5 files changed, 103 insertions(+), 12 deletions(-) create mode 100644 spec/fixtures/files/post_comments.csv diff --git a/lib/active_admin_import/dsl.rb b/lib/active_admin_import/dsl.rb index 405caeb..7352c1f 100644 --- a/lib/active_admin_import/dsl.rb +++ b/lib/active_admin_import/dsl.rb @@ -25,6 +25,20 @@ module ActiveAdminImport # +plural_resource_label+:: pluralized resource label value (default config.plural_resource_label) # module DSL + CONTEXT_METHOD = :active_admin_import_context + + def self.build_template_object(template_object) + template_object.is_a?(Proc) ? template_object.call : template_object + end + + def self.apply_import_context(model, controller) + return unless controller.respond_to?(CONTEXT_METHOD, true) + context = controller.send(CONTEXT_METHOD) + return unless context.is_a?(Hash) + context = context.merge(file: model.file) if model.respond_to?(:file) && !context.key?(:file) + model.assign_attributes(context) + end + DEFAULT_RESULT_PROC = lambda do |result, options| model_name = options[:resource_label].downcase plural_model_name = options[:plural_resource_label].downcase @@ -57,11 +71,8 @@ def active_admin_import(options = {}, &block) collection_action :import, method: :get do authorize!(ActiveAdminImport::Auth::IMPORT, active_admin_config.resource_class) - @active_admin_import_model = if options[:template_object].is_a?(Proc) - options[:template_object].call - else - options[:template_object] - end + @active_admin_import_model = ActiveAdminImport::DSL.build_template_object(options[:template_object]) + ActiveAdminImport::DSL.apply_import_context(@active_admin_import_model, self) render template: options[:template] end @@ -78,13 +89,10 @@ def active_admin_import(options = {}, &block) authorize!(ActiveAdminImport::Auth::IMPORT, active_admin_config.resource_class) _params = params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h : params params = ActiveSupport::HashWithIndifferentAccess.new _params - @active_admin_import_model = if options[:template_object].is_a?(Proc) - options[:template_object].call - else - options[:template_object] - end + @active_admin_import_model = ActiveAdminImport::DSL.build_template_object(options[:template_object]) params_key = ActiveModel::Naming.param_key(@active_admin_import_model.class) @active_admin_import_model.assign_attributes(params[params_key].try(:deep_symbolize_keys) || {}) + ActiveAdminImport::DSL.apply_import_context(@active_admin_import_model, self) # go back to form return render template: options[:template] unless @active_admin_import_model.valid? @importer = Importer.new( diff --git a/spec/fixtures/files/post_comments.csv b/spec/fixtures/files/post_comments.csv new file mode 100644 index 0000000..08582a5 --- /dev/null +++ b/spec/fixtures/files/post_comments.csv @@ -0,0 +1,3 @@ +"Body" +"First comment" +"Second comment" diff --git a/spec/import_spec.rb b/spec/import_spec.rb index 39be155..e859716 100644 --- a/spec/import_spec.rb +++ b/spec/import_spec.rb @@ -626,4 +626,67 @@ def upload_file!(name, ext = 'csv') end.not_to change { Author.count } end end + + context 'with active_admin_import_context defined on the controller' do + before { Author.create!(name: 'John', last_name: 'Doe') } + + let(:author) { Author.take } + + context 'when context returns request-derived attributes' do + before do + author_id = author.id + add_post_resource( + template_object: ActiveAdminImport::Model.new(author_id: author_id), + before_batch_import: lambda do |importer| + ip = importer.model.request_ip + a_id = importer.model.author_id + importer.csv_lines.map! { |row| row << ip << a_id } + importer.headers.merge!(:'Request Ip' => :request_ip, :'Author Id' => :author_id) + end, + controller_block: proc do + def active_admin_import_context + { request_ip: request.remote_ip } + end + end + ) + visit '/admin/posts/import' + upload_file!(:posts_for_author) + end + + it 'merges the context into the import model so callbacks see it' do + expect(page).to have_content 'Successfully imported 2 posts' + expect(Post.count).to eq(2) + Post.all.each do |post| + expect(post.request_ip).to eq('127.0.0.1') + expect(post.author_id).to eq(author.id) + end + end + end + + context 'when context returns parent id for a nested belongs_to resource' do + let(:post) { Post.create!(title: 'A post', body: 'body', author: author) } + + before do + add_nested_post_comment_resource( + before_batch_import: lambda do |importer| + importer.csv_lines.map! { |row| row << importer.model.post_id } + importer.headers.merge!(:'Post Id' => :post_id) + end, + controller_block: proc do + def active_admin_import_context + { post_id: parent.id } + end + end + ) + visit "/admin/posts/#{post.id}/post_comments/import" + upload_file!(:post_comments) + end + + it 'automatically assigns the parent post_id to every imported comment' do + expect(page).to have_content 'Successfully imported 2 post comments' + expect(PostComment.count).to eq(2) + expect(PostComment.where(post_id: post.id).count).to eq(2) + end + end + end end diff --git a/spec/support/admin.rb b/spec/support/admin.rb index 03fb0bd..e168535 100644 --- a/spec/support/admin.rb +++ b/spec/support/admin.rb @@ -8,8 +8,24 @@ def add_author_resource(options = {}, &block) end def add_post_resource(options = {}, &block) + cb = options.delete(:controller_block) ActiveAdmin.register Post do config.filters = false + controller(&cb) if cb + active_admin_import(options, &block) + end + Rails.application.reload_routes! +end + +def add_nested_post_comment_resource(options = {}, &block) + cb = options.delete(:controller_block) + ActiveAdmin.register Post do + config.filters = false + end + ActiveAdmin.register PostComment do + config.filters = false + belongs_to :post + controller(&cb) if cb active_admin_import(options, &block) end Rails.application.reload_routes! diff --git a/spec/support/rails_template.rb b/spec/support/rails_template.rb index 682c1c7..f821edf 100644 --- a/spec/support/rails_template.rb +++ b/spec/support/rails_template.rb @@ -1,10 +1,11 @@ create_file "app/assets/config/manifest.js", skip: true generate :model, 'author name:string{10}:uniq last_name:string birthday:date --force' -generate :model, 'post title:string:uniq body:text author:references --force' +generate :model, 'post title:string:uniq body:text request_ip:string author:references --force' +generate :model, 'post_comment body:text post:references --force' inject_into_file 'app/models/author.rb', " validates_presence_of :name\n validates_uniqueness_of :last_name\n", before: 'end' -inject_into_file 'app/models/post.rb', " validates_presence_of :author\n", before: 'end' +inject_into_file 'app/models/post.rb', " validates_presence_of :author\n has_many :post_comments\n", before: 'end' # Add our local Active Admin to the load path (Rails 7.1+) gsub_file "config/environment.rb", From 8f60ef72769277b7ad7dfbbcfeb08abb1c60256a Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 8 Apr 2026 10:52:17 +0200 Subject: [PATCH 2/2] Consolidate template object building and context application MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces `build_template_object` and `apply_import_context` with a single `prepare_import_model` orchestrator that handles the whole pipeline: build the template object → assign form params (if any) → apply context. Both `:import` and `:do_import` collection actions now call one method instead of two, and the form-params extraction is encapsulated inside the helper instead of being duplicated at the call site. --- lib/active_admin_import/dsl.rb | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/lib/active_admin_import/dsl.rb b/lib/active_admin_import/dsl.rb index 7352c1f..eb6349a 100644 --- a/lib/active_admin_import/dsl.rb +++ b/lib/active_admin_import/dsl.rb @@ -27,16 +27,18 @@ module ActiveAdminImport module DSL CONTEXT_METHOD = :active_admin_import_context - def self.build_template_object(template_object) - template_object.is_a?(Proc) ? template_object.call : template_object - end - - def self.apply_import_context(model, controller) - return unless controller.respond_to?(CONTEXT_METHOD, true) + def self.prepare_import_model(template_object, controller, params: nil) + model = template_object.is_a?(Proc) ? template_object.call : template_object + if params + params_key = ActiveModel::Naming.param_key(model.class) + model.assign_attributes(params[params_key].try(:deep_symbolize_keys) || {}) + end + return model unless controller.respond_to?(CONTEXT_METHOD, true) context = controller.send(CONTEXT_METHOD) - return unless context.is_a?(Hash) + return model unless context.is_a?(Hash) context = context.merge(file: model.file) if model.respond_to?(:file) && !context.key?(:file) model.assign_attributes(context) + model end DEFAULT_RESULT_PROC = lambda do |result, options| @@ -71,8 +73,7 @@ def active_admin_import(options = {}, &block) collection_action :import, method: :get do authorize!(ActiveAdminImport::Auth::IMPORT, active_admin_config.resource_class) - @active_admin_import_model = ActiveAdminImport::DSL.build_template_object(options[:template_object]) - ActiveAdminImport::DSL.apply_import_context(@active_admin_import_model, self) + @active_admin_import_model = ActiveAdminImport::DSL.prepare_import_model(options[:template_object], self) render template: options[:template] end @@ -89,10 +90,9 @@ def active_admin_import(options = {}, &block) authorize!(ActiveAdminImport::Auth::IMPORT, active_admin_config.resource_class) _params = params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h : params params = ActiveSupport::HashWithIndifferentAccess.new _params - @active_admin_import_model = ActiveAdminImport::DSL.build_template_object(options[:template_object]) - params_key = ActiveModel::Naming.param_key(@active_admin_import_model.class) - @active_admin_import_model.assign_attributes(params[params_key].try(:deep_symbolize_keys) || {}) - ActiveAdminImport::DSL.apply_import_context(@active_admin_import_model, self) + @active_admin_import_model = ActiveAdminImport::DSL.prepare_import_model( + options[:template_object], self, params: params + ) # go back to form return render template: options[:template] unless @active_admin_import_model.valid? @importer = Importer.new(