ixport 0.3.2 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 03be028cedf3161974f8143be44d6f31d79cc669e0388d266bc787d12a9dcd9e
4
- data.tar.gz: e7aec59ef0d4d8288a4062e713f0ef87eb7bc23f3bbda19db8e638eabc2872ad
3
+ metadata.gz: 317e30ac3c1fe5057cfe6ce21402e6e8e73cb4076966fee0b030f67eed212da8
4
+ data.tar.gz: 41f70d3512416fb810524d1defe824d7b1d550918542abda43b437a0a5ee93c3
5
5
  SHA512:
6
- metadata.gz: 2afc07d8cba3ef5d3272d5e74d4981ffd882c4dafe969d70af85dd85947049acea43356b804b8afc7bc4f3a918027f3a6aecebe990c774686cef254420f3873f
7
- data.tar.gz: eb1aba5fb3eecb5071f3ce89fd847eea67d0e79bf6270f639fc0d0c24bb92c3216e0ce8924a191622b07c07afae1af004312523db31dcf89436f527ba0897731
6
+ metadata.gz: dca598f882beb292b8159f43bac75cd00d29cff9ad7d85ef15fbb5d090fde8048def9377cc2e25880de9ac0ab7d7ff20d09e029198752ba0556dc5ea0026aac3
7
+ data.tar.gz: 816ee37b957e2adfc0734aab39a35a48da427a614ec923a5a248034af8f3b77d95b17e2bb170c94a5171e1efa0929daeaf46b130617dd9586267387165c5a047
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
@@ -7,13 +7,14 @@ module Ixport
7
7
 
8
8
  private
9
9
 
10
- def allowed?(registration)
11
- registration.allows?(ixport_person, ixport_account)
10
+ def allowed?(registration, target = nil)
11
+ registration.allows?(ixport_person, ixport_account, target)
12
12
  end
13
13
 
14
14
  def find_import
15
15
  @import = Import.find_by(id: params[:import_id] || params[:id], person: ixport_person, account: ixport_account)
16
- head :not_found unless @import && allowed?(Ixport.registration(@import.key))
16
+ registration = @import && Ixport.registrations[@import.key.to_sym]
17
+ head :not_found unless registration && allowed?(registration, @import.target)
17
18
  end
18
19
 
19
20
  def ixport_person
@@ -10,8 +10,12 @@ module Ixport
10
10
  end
11
11
 
12
12
  def create
13
- ExportTemplate.create!(key: @registration.key, name: params[:name], columns: known_columns(@registration), person: ixport_person, account: ixport_account)
14
- redirect_to exports_path
13
+ template = ExportTemplate.new(key: @registration.key, name: params[:name], columns: known_columns(@registration), person: ixport_person, account: ixport_account)
14
+ return redirect_to exports_path if template.save
15
+
16
+ @columns = @registration.exporter.constantize.columns
17
+ @name_error = template.errors[:name].first
18
+ render :new, status: :unprocessable_entity
15
19
  end
16
20
 
17
21
  def show
@@ -24,8 +28,12 @@ module Ixport
24
28
  end
25
29
 
26
30
  def update
27
- @template.update!(name: params[:name], columns: known_columns(@template.registration))
28
- redirect_to exports_path
31
+ return redirect_to exports_path if @template.update(name: params[:name], columns: known_columns(@template.registration))
32
+
33
+ @columns = @template.registration.exporter.constantize.columns
34
+ @name_error = @template.errors[:name].first
35
+ @template.restore_attributes
36
+ render :edit, status: :unprocessable_entity
29
37
  end
30
38
 
31
39
  def destroy
@@ -37,7 +45,8 @@ module Ixport
37
45
 
38
46
  def find_template
39
47
  @template = ExportTemplate.find_by(id: params[:id], account: ixport_account)
40
- head :not_found unless @template && allowed?(@template.registration)
48
+ registration = @template && Ixport.export_registrations[@template.key.to_sym]
49
+ head :not_found unless registration && allowed?(registration)
41
50
  end
42
51
 
43
52
  def find_chosen_export
@@ -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
@@ -7,15 +7,28 @@ module Ixport
7
7
  def create
8
8
  runner = Ixport.background_runner
9
9
  return run_in_request unless runner
10
- return redirect_to import_path(@import) unless @import.status == "pending"
10
+ return redirect_to import_path(@import) unless claim_pending_import
11
11
 
12
- @import.update!(status: "running")
13
- runner.enqueue(@import)
14
- redirect_to @import.wallflower_task_id ? wallflower.task_path(@import.wallflower_task_id) : import_path(@import)
12
+ hand_over(runner)
15
13
  end
16
14
 
17
15
  private
18
16
 
17
+ def claim_pending_import
18
+ claimed = Import.where(id: @import.id, status: "pending").update_all(status: "running", updated_at: Time.current) == 1
19
+ @import.reload if claimed
20
+ claimed
21
+ end
22
+
23
+ def hand_over(runner)
24
+ runner.enqueue(@import)
25
+ redirect_to @import.wallflower_task_id ? wallflower.task_path(@import.wallflower_task_id) : import_path(@import)
26
+ rescue StandardError => error
27
+ Rails.error.report(error)
28
+ @import.update!(status: "pending")
29
+ redirect_to import_preview_path(@import), flash: { ixport_not_started: true }
30
+ end
31
+
19
32
  def run_in_request
20
33
  @import.run!
21
34
  redirect_to import_path(@import)
@@ -7,6 +7,8 @@ module Ixport
7
7
  belongs_to :person, polymorphic: true
8
8
  belongs_to :account, polymorphic: true, optional: true
9
9
 
10
+ validates :name, presence: { message: "Give the template a name." }
11
+
10
12
  def registration
11
13
  Ixport.export_registration(key)
12
14
  end
@@ -8,15 +8,20 @@ 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
14
15
 
16
+ STALLED_AFTER = 1.hour
17
+
15
18
  validate :incoming_file_is_usable, on: :create
16
19
 
17
- def run!(reporter: NullReporter.new)
20
+ attr_reader :run_error
21
+
22
+ def run!(reporter: NullReporter.new, report_failure: true)
18
23
  with_lock do
19
- walk_and_save(reporter) if status.in?(%w[pending running])
24
+ walk_and_save(reporter, report_failure) if status.in?(%w[pending running])
20
25
  end
21
26
  end
22
27
 
@@ -28,6 +33,10 @@ module Ixport
28
33
  status == "running"
29
34
  end
30
35
 
36
+ def stalled?
37
+ running? && updated_at < STALLED_AFTER.ago
38
+ end
39
+
31
40
  def failed?
32
41
  status == "failed"
33
42
  end
@@ -46,8 +55,12 @@ module Ixport
46
55
  RowWalk.new(self, importer).rows
47
56
  end
48
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
+
49
62
  def importer
50
- importer_class.new(person: person, account: account)
63
+ importer_class.new(person: person, account: account, target: target)
51
64
  end
52
65
 
53
66
  def importer_class
@@ -72,21 +85,40 @@ module Ixport
72
85
 
73
86
  private
74
87
 
75
- def walk_and_save(reporter)
88
+ def walk_and_save(reporter, report_failure)
76
89
  counts = { add: 0, update: 0 }
77
- rows = self.rows
90
+ rows = rows_to_walk
78
91
  reporter.set_total(rows.size)
79
- rows.each do |row|
80
- save_row(row, reporter)
81
- counts[row.outcome] += 1 if counts.key?(row.outcome)
82
- 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
83
98
  end
84
99
  update!(status: "finished", added_count: counts[:add], updated_count: counts[:update])
85
100
  rescue StandardError => error
86
- Rails.error.report(error)
101
+ @run_error = error
102
+ Rails.error.report(error) if report_failure
87
103
  update!(status: "failed", error_message: error.message, added_count: counts[:add], updated_count: counts[:update])
88
104
  end
89
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
+
90
122
  def save_row(row, reporter)
91
123
  if row.outcome == :refused
92
124
  refusals.create!(row_number: row.number, reason: row.reason)
@@ -6,9 +6,9 @@ module Ixport
6
6
 
7
7
  def call(task)
8
8
  import = Import.find(task.params["import_id"])
9
- Ixport.run_import(import, reporter: task)
9
+ import.run!(reporter: task, report_failure: false)
10
10
  task.link_result(ixport.import_path(import))
11
- raise ImportFailed, import.error_message if import.failed?
11
+ raise(import.run_error || ImportFailed.new(import.error_message)) if import.failed?
12
12
  end
13
13
  end
14
14
  end
@@ -1,7 +1,7 @@
1
1
  <%= ui_form_page(title: "Edit #{@template.name}", subtitle: @template.registration.title, back_url: exports_path) %>
2
2
  <%= ui_page(max_width: :md) do %>
3
3
  <%= ui_form(action: export_template_path(@template), method: :patch) do %>
4
- <%= ui_form_field(attribute: "name", label: "Template name", required: true, value: @template.name) %>
4
+ <%= ui_form_field(attribute: "name", label: "Template name", required: true, value: @template.name, errors: @name_error.present? ? [tag.span(@name_error, data: { name_error: true })] : []) %>
5
5
  <% column_options = @columns.map { |column| [column.label, column.key] } %>
6
6
  <% @columns.each_index do |index| %>
7
7
  <%= ui_form_field(attribute: "columns[]", label: "Column #{index + 1}", type: :select, options: column_options, include_blank: "None", value: @template.columns[index]) %>
@@ -1,7 +1,7 @@
1
1
  <%= ui_form_page(title: "New #{@registration.title} template", back_url: exports_path) %>
2
2
  <%= ui_page(max_width: :md) do %>
3
3
  <%= ui_form(action: export_templates_path(@registration.key)) do %>
4
- <%= ui_form_field(attribute: "name", label: "Template name", required: true) %>
4
+ <%= ui_form_field(attribute: "name", label: "Template name", required: true, errors: @name_error.present? ? [tag.span(@name_error, data: { name_error: true })] : []) %>
5
5
  <% column_options = @columns.map { |column| [column.label, column.key] } %>
6
6
  <% @columns.each_index do |index| %>
7
7
  <%= ui_form_field(attribute: "columns[]", label: "Column #{index + 1}", type: :select, options: column_options, include_blank: "None") %>
@@ -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 %>
@@ -1,4 +1,6 @@
1
- <% heading = if @import.running?
1
+ <% heading = if @import.stalled?
2
+ "Import stalled"
3
+ elsif @import.running?
2
4
  "Import running"
3
5
  elsif @import.failed?
4
6
  "Import failed"
@@ -8,7 +10,10 @@
8
10
  <%= ui_show_page(title: heading, back_url: root_path) %>
9
11
  <%= ui_page(max_width: :md) do %>
10
12
  <%= ui_page_header(title: heading) %>
11
- <% if @import.running? %>
13
+ <% if @import.stalled? %>
14
+ <%= ui_alert(type: :error, message: tag.span("This import has stopped making progress.", data: { import_stalled: true })) %>
15
+ <p>Rows saved before it stopped stay saved.</p>
16
+ <% elsif @import.running? %>
12
17
  <%= ui_alert(type: :info, message: tag.span("This import is running.", data: { import_running: true })) %>
13
18
  <% else %>
14
19
  <% if @import.failed? %>
@@ -1,6 +1,9 @@
1
1
  <%= ui_show_page(title: "Preview", back_url: import_mapping_path(@import)) %>
2
2
  <%= ui_page(max_width: :md) do %>
3
3
  <%= ui_page_header(title: "Preview") %>
4
+ <% if flash[:ixport_not_started].present? %>
5
+ <%= ui_alert(type: :error, message: tag.span("This import did not start. Run it again.", data: { import_not_started: true })) %>
6
+ <% end %>
4
7
  <%= ui_grid(cols: { default: 1, sm: 3 }) do %>
5
8
  <%= ui_stat_card(label: "Will add", value: tag.span(@counts[:add], data: { count: "add" })) %>
6
9
  <%= ui_stat_card(label: "Will update", value: tag.span(@counts[:update], data: { count: "update" })) %>
@@ -29,7 +32,11 @@
29
32
  </ul>
30
33
  <% end %>
31
34
  <% end %>
32
- <%= ui_form(action: import_run_path(@import)) do %>
33
- <%= 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 %>
34
41
  <% end %>
35
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.2"
4
+ VERSION = "0.4.0"
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.2
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tyler Schneider
@@ -118,6 +118,7 @@ files:
118
118
  - config/routes.rb
119
119
  - lib/generators/ixport/install/install_generator.rb
120
120
  - lib/generators/ixport/install/templates/add_error_message_to_ixport_imports.rb.erb
121
+ - lib/generators/ixport/install/templates/add_target_to_ixport_imports.rb.erb
121
122
  - lib/generators/ixport/install/templates/add_wallflower_task_to_ixport_imports.rb.erb
122
123
  - lib/generators/ixport/install/templates/create_ixport_export_templates.rb.erb
123
124
  - lib/generators/ixport/install/templates/create_ixport_import_refusals.rb.erb