ixport 0.3.3 → 0.4.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b40a581b96b5e012197ee7a185f08891e4580fb7f2bc824ddd5de3789ccb9e02
4
- data.tar.gz: 9f6e9bde4f0590cfac3524df4695bff2ee132ad47cd8d66938b919fbf5ec2c67
3
+ metadata.gz: 16c99636a4d8ef113f4f79a6329f2dd4863cc8ade7ad53b50c988738da2e3701
4
+ data.tar.gz: 54549f7009304e78c9d53de1719061c6763a36c1c721619af015d077e02ecf42
5
5
  SHA512:
6
- metadata.gz: 6f3dde1eac2e6e592a49fcf9049c822efa56e70468cbe1666329c8be35beef75e94e03091786bf94f8f0f141619e48a338fa99ecc1b349f6e547461569a7d12d
7
- data.tar.gz: 0af9d0471ea9d3e62a0476e1e6ef9a7fa8d9bff8050e90576d7d226e53c620bf90f62261930e52a43a9115b3a97b4b5d0daa450b999d618c3576f2a61bd1db35
6
+ metadata.gz: c023605435d19ea4ca5c6477f1684cd4d42a49200cfd2370e211d697ca705a8850f3071919cb9a8df89362e1e47a1235c1eaa087b0e6f7bf6dfceadcf4c160c3
7
+ data.tar.gz: 5da35e3a802248a422c462ba7d8f30876e3ce2504156e06ef13d251da191b5571f788e838be400eadab03e31c38fd7f5bdb1c2c832db48f2721feb02b414d0c2
data/README.md CHANGED
@@ -77,6 +77,39 @@ Each field's key is the record's attribute name. ixport looks up every match val
77
77
 
78
78
  To match on several fields, name them all, for example `match_on :happened_on, :amount, :name`.
79
79
 
80
+ ## Importing into a chosen record
81
+
82
+ An import can add its rows to one record the app chooses, such as questions uploaded into one quiz. The app names that record in the link to the upload page, as its Global ID:
83
+
84
+ ```erb
85
+ <%= link_to "Import questions", ixport.new_import_path(key: :quiz_questions, target: @quiz.to_global_id) %>
86
+ ```
87
+
88
+ The import keeps that record through upload, mapping, preview and run, and its importer reaches it as `target`:
89
+
90
+ ```ruby
91
+ class QuizQuestionImporter < Ixport::Importer
92
+ field :prompt, label: "Question"
93
+ match_on :prompt
94
+
95
+ def records
96
+ target.questions
97
+ end
98
+ end
99
+ ```
100
+
101
+ A link naming a record that does not exist answers not found. Anyone can edit the link, so the registration's `allow:` rule decides which records a person may import into, as described under [Who may import or export](#who-may-import-or-export). An import opened with no record has a `target` of `nil` and works as before. Run `bin/rails generate ixport:update` after updating, for the migration that lets an import keep its record.
102
+
103
+ ## Refusing a whole file
104
+
105
+ An import can be registered to save nothing from a file in which any row is refused, so a partial import never leaves half of a quiz's questions added:
106
+
107
+ ```ruby
108
+ Ixport.register_import(:quiz_questions, title: "Quiz questions", importer: "QuizQuestionImporter", refuse_whole_file: true)
109
+ ```
110
+
111
+ While any row is refused, its preview offers no Run import button and says to fix the refused rows in the file and upload it again. A run of such a file, inside the request or in the background, saves no row, records each refused row and counts no row added or updated. When every row is valid, every row is saved. If the run stops on an error, every row it saved is undone and it counts no row added or updated. Imports registered without `refuse_whole_file` work as before.
112
+
80
113
  ## Uploading and mapping a file
81
114
 
82
115
  From the engine's root page, a person picks a registered import and uploads a CSV for it. The upload is refused, with a message on the upload page, when the file is not UTF-8 text, when its quoting is broken (the message names the line where reading stopped), when it has no header row, or, in an app with no background runner, when it has more rows than `config.inline_row_limit`. A byte order mark at the start of the file is ignored, so the first column keeps its name.
@@ -164,6 +197,15 @@ Ixport.register_import :products, title: "Products", importer: "ProductImporter"
164
197
  allow: ->(person, account) { ProductImportPolicy.new(person, account).create? }
165
198
  ```
166
199
 
200
+ A rule that takes a third argument also receives the import's chosen record, or `nil` when there is none, and returns false to refuse it:
201
+
202
+ ```ruby
203
+ Ixport.register_import :quiz_questions, title: "Quiz questions", importer: "QuizQuestionImporter",
204
+ allow: ->(person, account, quiz) { quiz.nil? || quiz.account == account }
205
+ ```
206
+
207
+ A link or upload naming a record the rule refuses answers not found, and so does every page of an import whose record it refuses. A rule that takes two arguments is called as before.
208
+
167
209
  A person the rule refuses does not see that import or export listed, nor their past imports of it, and every one of its pages and downloads answers not found. A registration with no rule is open to every signed-in person.
168
210
 
169
211
  ## Claude Code agents
@@ -4,17 +4,20 @@ module Ixport
4
4
  class ApplicationController < Ixport.configuration.base_controller.constantize
5
5
  before_action { send(Ixport.configuration.authentication_method) }
6
6
  layout -> { Ixport.configuration.layout }
7
+ helper AppRoutesHelper
8
+
9
+ before_action { AppRoutesHelper.define_app_route_helpers }
7
10
 
8
11
  private
9
12
 
10
- def allowed?(registration)
11
- registration.allows?(ixport_person, ixport_account)
13
+ def allowed?(registration, target = nil)
14
+ registration.allows?(ixport_person, ixport_account, target)
12
15
  end
13
16
 
14
17
  def find_import
15
18
  @import = Import.find_by(id: params[:import_id] || params[:id], person: ixport_person, account: ixport_account)
16
19
  registration = @import && Ixport.registrations[@import.key.to_sym]
17
- head :not_found unless registration && allowed?(registration)
20
+ head :not_found unless registration && allowed?(registration, @import.target)
18
21
  end
19
22
 
20
23
  def ixport_person
@@ -4,6 +4,7 @@ module Ixport
4
4
  class ImportsController < ApplicationController
5
5
  before_action :find_registration, only: %i[new create]
6
6
  before_action :find_import, only: :show
7
+ before_action :find_fields, only: %i[new create]
7
8
 
8
9
  def index
9
10
  @registrations = Ixport.registrations.values.select { |registration| allowed?(registration) }
@@ -18,7 +19,7 @@ module Ixport
18
19
  end
19
20
 
20
21
  def create
21
- import = Import.new(key: @registration.key, person: ixport_person, account: ixport_account, file: params[:file])
22
+ import = Import.new(key: @registration.key, person: ixport_person, account: ixport_account, target: @target, file: params[:file])
22
23
  if import.save
23
24
  redirect_to import_mapping_path(import)
24
25
  else
@@ -31,7 +32,18 @@ module Ixport
31
32
 
32
33
  def find_registration
33
34
  @registration = Ixport.registrations[params[:key].to_s.to_sym]
34
- head :not_found unless @registration && allowed?(@registration)
35
+ @target = chosen_target
36
+ head :not_found unless @registration && (@target || params[:target].blank?) && allowed?(@registration, @target)
37
+ end
38
+
39
+ def find_fields
40
+ @fields = @registration.importer.constantize.fields
41
+ end
42
+
43
+ def chosen_target
44
+ GlobalID::Locator.locate(params[:target]) if params[:target].present?
45
+ rescue ActiveRecord::RecordNotFound, NameError
46
+ nil
35
47
  end
36
48
  end
37
49
  end
@@ -11,6 +11,7 @@ module Ixport
11
11
  @counts = { add: 0, update: 0, refused: 0 }.merge(rows.group_by(&:outcome).transform_values(&:size))
12
12
  @update_rows = rows.select { |row| row.outcome == :update }
13
13
  @refused_rows = rows.select { |row| row.outcome == :refused }
14
+ @whole_file_refused = @import.whole_file_refused?(rows)
14
15
  end
15
16
  end
16
17
  end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ixport
4
+ module AppRoutesHelper
5
+ def self.define_app_route_helpers
6
+ @app_route_helpers_defined ||= begin
7
+ (Rails.application.routes.named_routes.helper_names - Ixport::Engine.routes.named_routes.helper_names).each do |name|
8
+ define_method(name) { |*args, **options| main_app.public_send(name, *args, **options) }
9
+ end
10
+ true
11
+ end
12
+ end
13
+ end
14
+ end
@@ -8,6 +8,7 @@ module Ixport
8
8
 
9
9
  belongs_to :person, polymorphic: true
10
10
  belongs_to :account, polymorphic: true, optional: true
11
+ belongs_to :target, polymorphic: true, optional: true
11
12
 
12
13
  has_one_attached :file
13
14
  has_many :refusals, -> { order(:row_number) }, class_name: "Ixport::ImportRefusal", dependent: :destroy
@@ -54,8 +55,12 @@ module Ixport
54
55
  RowWalk.new(self, importer).rows
55
56
  end
56
57
 
58
+ def whole_file_refused?(rows = self.rows)
59
+ Ixport.registration(key).refuse_whole_file && rows.any? { |row| row.outcome == :refused }
60
+ end
61
+
57
62
  def importer
58
- importer_class.new(person: person, account: account)
63
+ importer_class.new(person: person, account: account, target: target)
59
64
  end
60
65
 
61
66
  def importer_class
@@ -82,12 +87,14 @@ module Ixport
82
87
 
83
88
  def walk_and_save(reporter, report_failure)
84
89
  counts = { add: 0, update: 0 }
85
- rows = self.rows
90
+ rows = rows_to_walk
86
91
  reporter.set_total(rows.size)
87
- rows.each do |row|
88
- save_row(row, reporter)
89
- counts[row.outcome] += 1 if counts.key?(row.outcome)
90
- reporter.advance
92
+ saving_together(counts) do
93
+ rows.each do |row|
94
+ save_row(row, reporter)
95
+ counts[row.outcome] += 1 if counts.key?(row.outcome)
96
+ reporter.advance
97
+ end
91
98
  end
92
99
  update!(status: "finished", added_count: counts[:add], updated_count: counts[:update])
93
100
  rescue StandardError => error
@@ -96,6 +103,22 @@ module Ixport
96
103
  update!(status: "failed", error_message: error.message, added_count: counts[:add], updated_count: counts[:update])
97
104
  end
98
105
 
106
+ def saving_together(counts, &block)
107
+ return yield unless Ixport.registration(key).refuse_whole_file
108
+
109
+ begin
110
+ self.class.transaction(requires_new: true, &block)
111
+ rescue StandardError
112
+ counts.transform_values! { 0 }
113
+ raise
114
+ end
115
+ end
116
+
117
+ def rows_to_walk
118
+ rows = self.rows
119
+ whole_file_refused?(rows) ? rows.select { |row| row.outcome == :refused } : rows
120
+ end
121
+
99
122
  def save_row(row, reporter)
100
123
  if row.outcome == :refused
101
124
  refusals.create!(row_number: row.number, reason: row.reason)
@@ -3,8 +3,16 @@
3
3
  <% if @error.present? %>
4
4
  <%= ui_alert(type: :error, message: tag.span(@error, data: { upload_error: true })) %>
5
5
  <% end %>
6
+ <%= ui_section(title: "Fields this import expects") do %>
7
+ <ul>
8
+ <% @fields.each do |field| %>
9
+ <%= tag.li(field.label, data: { expected_field: true }) %>
10
+ <% end %>
11
+ </ul>
12
+ <% end %>
6
13
  <%= ui_form(action: imports_path, multipart: true) do %>
7
14
  <%= hidden_field_tag "key", @registration.key %>
15
+ <%= hidden_field_tag "target", @target.to_global_id if @target %>
8
16
  <%= ui_file_upload(name: "file", label: "CSV file", accept: ".csv") %>
9
17
  <%= ui_button(label: "Upload") %>
10
18
  <% end %>
@@ -32,7 +32,11 @@
32
32
  </ul>
33
33
  <% end %>
34
34
  <% end %>
35
- <%= ui_form(action: import_run_path(@import)) do %>
36
- <%= ui_button(label: "Run import") %>
35
+ <% if @whole_file_refused %>
36
+ <%= ui_alert(type: :error, message: tag.span("This import saves no row while any row is refused. Fix the refused rows in the file and upload it again.", data: { whole_file_refused: true })) %>
37
+ <% else %>
38
+ <%= ui_form(action: import_run_path(@import)) do %>
39
+ <%= ui_button(label: "Run import") %>
40
+ <% end %>
37
41
  <% end %>
38
42
  <% end %>
@@ -18,6 +18,7 @@ module Ixport
18
18
  migration_template "create_ixport_export_templates.rb.erb", "db/migrate/create_ixport_export_templates.rb"
19
19
  migration_template "add_wallflower_task_to_ixport_imports.rb.erb", "db/migrate/add_wallflower_task_to_ixport_imports.rb"
20
20
  migration_template "add_error_message_to_ixport_imports.rb.erb", "db/migrate/add_error_message_to_ixport_imports.rb"
21
+ migration_template "add_target_to_ixport_imports.rb.erb", "db/migrate/add_target_to_ixport_imports.rb"
21
22
  end
22
23
  end
23
24
  end
@@ -0,0 +1,5 @@
1
+ class AddTargetToIxportImports < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ add_reference :ixport_imports, :target, polymorphic: true, null: true
4
+ end
5
+ end
@@ -23,6 +23,10 @@ module Ixport
23
23
  def copy_error_message_migration
24
24
  migration_template "add_error_message_to_ixport_imports.rb.erb", "db/migrate/add_error_message_to_ixport_imports.rb"
25
25
  end
26
+
27
+ def copy_target_migration
28
+ migration_template "add_target_to_ixport_imports.rb.erb", "db/migrate/add_target_to_ixport_imports.rb"
29
+ end
26
30
  end
27
31
  end
28
32
  end
@@ -4,11 +4,12 @@ module Ixport
4
4
  class Importer
5
5
  Field = Struct.new(:key, :label, keyword_init: true)
6
6
 
7
- attr_reader :person, :account
7
+ attr_reader :person, :account, :target
8
8
 
9
- def initialize(person:, account:)
9
+ def initialize(person:, account:, target: nil)
10
10
  @person = person
11
11
  @account = account
12
+ @target = target
12
13
  end
13
14
 
14
15
  def self.field(key, label:)
@@ -2,19 +2,22 @@
2
2
 
3
3
  module Ixport
4
4
  module AccessRule
5
- def allows?(person, account)
6
- allow.nil? || allow.call(person, account)
5
+ def allows?(person, account, target = nil)
6
+ return true if allow.nil?
7
+ return allow.call(person, account) if allow.arity == 2
8
+
9
+ allow.call(person, account, target)
7
10
  end
8
11
  end
9
12
 
10
- Registration = Struct.new(:key, :title, :importer, :allow, keyword_init: true) { include AccessRule }
13
+ Registration = Struct.new(:key, :title, :importer, :allow, :refuse_whole_file, keyword_init: true) { include AccessRule }
11
14
  ExportRegistration = Struct.new(:key, :title, :exporter, :chosen, :allow, keyword_init: true) { include AccessRule }
12
15
 
13
16
  class MissingImporter < StandardError; end
14
17
  class MissingExporter < StandardError; end
15
18
 
16
- def self.register_import(key, title:, importer:, allow: nil)
17
- registrations[key.to_sym] = Registration.new(key: key.to_sym, title: title, importer: importer.to_s, allow: allow)
19
+ def self.register_import(key, title:, importer:, allow: nil, refuse_whole_file: false)
20
+ registrations[key.to_sym] = Registration.new(key: key.to_sym, title: title, importer: importer.to_s, allow: allow, refuse_whole_file: refuse_whole_file)
18
21
  end
19
22
 
20
23
  def self.registration(key)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ixport
4
- VERSION = "0.3.3"
4
+ VERSION = "0.4.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ixport
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.3
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tyler Schneider
@@ -97,6 +97,7 @@ files:
97
97
  - app/controllers/ixport/mappings_controller.rb
98
98
  - app/controllers/ixport/previews_controller.rb
99
99
  - app/controllers/ixport/runs_controller.rb
100
+ - app/helpers/ixport/app_routes_helper.rb
100
101
  - app/helpers/ixport/imports_helper.rb
101
102
  - app/models/ixport/application_record.rb
102
103
  - app/models/ixport/csv_writer.rb
@@ -118,6 +119,7 @@ files:
118
119
  - config/routes.rb
119
120
  - lib/generators/ixport/install/install_generator.rb
120
121
  - lib/generators/ixport/install/templates/add_error_message_to_ixport_imports.rb.erb
122
+ - lib/generators/ixport/install/templates/add_target_to_ixport_imports.rb.erb
121
123
  - lib/generators/ixport/install/templates/add_wallflower_task_to_ixport_imports.rb.erb
122
124
  - lib/generators/ixport/install/templates/create_ixport_export_templates.rb.erb
123
125
  - lib/generators/ixport/install/templates/create_ixport_import_refusals.rb.erb