ixport 0.3.1 → 0.3.3

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: 7010d4338968ec32338a7f02a446a59a091487b0860f955c090f5e180b61d65a
4
- data.tar.gz: 82f2bf80f348f9a308a0c3674efaf62441c0b4865cfe867e599243861b5227fe
3
+ metadata.gz: b40a581b96b5e012197ee7a185f08891e4580fb7f2bc824ddd5de3789ccb9e02
4
+ data.tar.gz: 9f6e9bde4f0590cfac3524df4695bff2ee132ad47cd8d66938b919fbf5ec2c67
5
5
  SHA512:
6
- metadata.gz: 363acc8c4d91440e255240436d96196607831fd98ebcab6d90fc3c817b94609aa5a2978ac16aab41eb3b6fd4371f9d565367eec0bb08c32596e351c647fdb683
7
- data.tar.gz: cda9b8842119709c211008e76820185f0d36544b831d718b8bfa743f0b681b4a9e79919b341b3a73acf0c600b98d15472a72632861b5d6a51635089bea0d935b
6
+ metadata.gz: 6f3dde1eac2e6e592a49fcf9049c822efa56e70468cbe1666329c8be35beef75e94e03091786bf94f8f0f141619e48a338fa99ecc1b349f6e547461569a7d12d
7
+ data.tar.gz: 0af9d0471ea9d3e62a0476e1e6ef9a7fa8d9bff8050e90576d7d226e53c620bf90f62261930e52a43a9115b3a97b4b5d0daa450b999d618c3576f2a61bd1db35
data/README.md CHANGED
@@ -81,7 +81,7 @@ To match on several fields, name them all, for example `match_on :happened_on, :
81
81
 
82
82
  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.
83
83
 
84
- After uploading, the person matches each column of the file's header row to one of the importer's fields, by its label, or skips it. Saving the mapping moves them on to the preview.
84
+ After uploading, the person matches each column of the file's header row to one of the importer's fields, by its label, or skips it. Saving the mapping moves them on to the preview, and going back to the mapping page shows the choices already made.
85
85
 
86
86
  The preview shows how many rows will add a record, update one or be refused, before anything is saved. It lists each row that will update a record and each refused row, by its row number in the file, with the reason the record gives for a refusal. A row is refused when the record it builds is not valid. Viewing the preview saves nothing, and the person can go back from it to change the mapping.
87
87
 
@@ -89,7 +89,7 @@ The preview shows how many rows will add a record, update one or be refused, bef
89
89
 
90
90
  The person runs the import from the preview. With no background runner configured, it runs inside the request. Each row that passes the record's validations is saved, updating the record it matches or adding a new one. Each row that fails them is refused and kept with its row number and reason, and the record it matched is left unchanged.
91
91
 
92
- The import's result page then shows how many rows were added and how many updated, and lists each refused row. It shows the same thing on every later visit. A finished import offers no Run button, its preview leads to its result page, and running it again adds or changes nothing.
92
+ The import's result page then shows its status, how many rows the file had, how many were added and how many updated, and lists each refused row. It shows the same thing on every later visit. A finished import offers no Run button, its preview leads to its result page, and running it again adds or changes nothing.
93
93
 
94
94
  An import belongs to the person and account that uploaded it, and anyone else gets not found when they open it.
95
95
 
@@ -13,7 +13,8 @@ module Ixport
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)
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
@@ -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
@@ -12,11 +12,15 @@ module Ixport
12
12
  has_one_attached :file
13
13
  has_many :refusals, -> { order(:row_number) }, class_name: "Ixport::ImportRefusal", dependent: :destroy
14
14
 
15
+ STALLED_AFTER = 1.hour
16
+
15
17
  validate :incoming_file_is_usable, on: :create
16
18
 
17
- def run!(reporter: NullReporter.new)
19
+ attr_reader :run_error
20
+
21
+ def run!(reporter: NullReporter.new, report_failure: true)
18
22
  with_lock do
19
- walk_and_save(reporter) if status.in?(%w[pending running])
23
+ walk_and_save(reporter, report_failure) if status.in?(%w[pending running])
20
24
  end
21
25
  end
22
26
 
@@ -28,6 +32,10 @@ module Ixport
28
32
  status == "running"
29
33
  end
30
34
 
35
+ def stalled?
36
+ running? && updated_at < STALLED_AFTER.ago
37
+ end
38
+
31
39
  def failed?
32
40
  status == "failed"
33
41
  end
@@ -54,6 +62,10 @@ module Ixport
54
62
  Ixport.registration(key).importer.constantize
55
63
  end
56
64
 
65
+ def row_count
66
+ [ CSV.parse(contents).size - 1, 0 ].max
67
+ end
68
+
57
69
  def headers
58
70
  CSV.parse_line(contents) || []
59
71
  end
@@ -68,7 +80,7 @@ module Ixport
68
80
 
69
81
  private
70
82
 
71
- def walk_and_save(reporter)
83
+ def walk_and_save(reporter, report_failure)
72
84
  counts = { add: 0, update: 0 }
73
85
  rows = self.rows
74
86
  reporter.set_total(rows.size)
@@ -79,7 +91,8 @@ module Ixport
79
91
  end
80
92
  update!(status: "finished", added_count: counts[:add], updated_count: counts[:update])
81
93
  rescue StandardError => error
82
- Rails.error.report(error)
94
+ @run_error = error
95
+ Rails.error.report(error) if report_failure
83
96
  update!(status: "failed", error_message: error.message, added_count: counts[:add], updated_count: counts[:update])
84
97
  end
85
98
 
@@ -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") %>
@@ -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,16 +10,21 @@
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? %>
15
20
  <%= ui_alert(type: :error, message: tag.span(@import.error_message, data: { import_error: true })) %>
16
21
  <p>Rows saved before it stopped stay saved.</p>
17
22
  <% end %>
18
- <%= ui_grid(cols: { default: 1, sm: 2 }) do %>
23
+ <%= ui_badge(label: tag.span(@import.status.humanize, data: { import_status: true }), variant: @import.failed? ? :danger : :success) %>
24
+ <%= ui_grid(cols: { default: 1, sm: 3 }) do %>
19
25
  <%= ui_stat_card(label: "Added", value: tag.span(@import.added_count, data: { result_count: "added" })) %>
20
26
  <%= ui_stat_card(label: "Updated", value: tag.span(@import.updated_count, data: { result_count: "updated" })) %>
27
+ <%= ui_stat_card(label: "Rows in file", value: tag.span(@import.row_count, data: { result_count: "total" })) %>
21
28
  <% end %>
22
29
  <% if @import.refusals.any? %>
23
30
  <%= ui_section(title: "Refused") do %>
@@ -1,10 +1,14 @@
1
1
  <%= ui_form_page(title: "Match columns", back_url: root_path) %>
2
2
  <%= ui_page(max_width: :md) do %>
3
3
  <%= ui_form(action: import_mapping_path(@import), method: :patch) do %>
4
- <% @import.headers.each_with_index do |header, index| %>
5
- <%= tag.div(data: { column: true }) do %>
6
- <%= tag.span(header, data: { column_name: true }) %>
7
- <%= ui_select(name: "mapping[#{index}]", options: @fields.map { |field| [field.label, field.key] }, include_blank: "Skip") %>
4
+ <%= ui_grid(cols: { default: 1 }, gap: :md) do %>
5
+ <% @import.headers.each_with_index do |header, index| %>
6
+ <%= tag.div(data: { column: true }) do %>
7
+ <%= ui_grid(cols: { default: 1, sm: 2 }, gap: :sm) do %>
8
+ <%= tag.span(header, data: { column_name: true }) %>
9
+ <%= ui_select(name: "mapping[#{index}]", options: @fields.map { |field| [field.label, field.key] }, selected: @import.mapping&.dig(index.to_s), include_blank: "Skip") %>
10
+ <% end %>
11
+ <% end %>
8
12
  <% end %>
9
13
  <% end %>
10
14
  <%= ui_button(label: "Save mapping") %>
@@ -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" })) %>
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ixport
4
- VERSION = "0.3.1"
4
+ VERSION = "0.3.3"
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.1
4
+ version: 0.3.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tyler Schneider