ixport 0.1.0 → 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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +85 -4
  3. data/app/controllers/ixport/application_controller.rb +5 -1
  4. data/app/controllers/ixport/export_templates_controller.rb +52 -0
  5. data/app/controllers/ixport/exports_controller.rb +18 -0
  6. data/app/controllers/ixport/imports_controller.rb +4 -3
  7. data/app/controllers/ixport/previews_controller.rb +2 -2
  8. data/app/controllers/ixport/runs_controller.rb +13 -1
  9. data/app/helpers/ixport/imports_helper.rb +13 -0
  10. data/app/models/ixport/csv_writer.rb +19 -0
  11. data/app/models/ixport/export_template.rb +19 -0
  12. data/app/models/ixport/import.rb +71 -19
  13. data/app/models/ixport/null_reporter.rb +11 -0
  14. data/app/models/ixport/row_walk.rb +1 -1
  15. data/app/models/ixport/wallflower_runner.rb +10 -0
  16. data/app/models/ixport/wallflower_task_runner.rb +14 -0
  17. data/app/views/ixport/export_templates/edit.html.erb +14 -0
  18. data/app/views/ixport/export_templates/new.html.erb +11 -0
  19. data/app/views/ixport/exports/index.html.erb +23 -0
  20. data/app/views/ixport/imports/index.html.erb +17 -0
  21. data/app/views/ixport/imports/show.html.erb +30 -15
  22. data/config/routes.rb +4 -0
  23. data/lib/generators/ixport/install/install_generator.rb +3 -0
  24. data/lib/generators/ixport/install/templates/add_error_message_to_ixport_imports.rb.erb +5 -0
  25. data/lib/generators/ixport/install/templates/add_wallflower_task_to_ixport_imports.rb.erb +6 -0
  26. data/lib/generators/ixport/install/templates/create_ixport_export_templates.rb.erb +12 -0
  27. data/lib/generators/ixport/update/update_generator.rb +28 -0
  28. data/lib/ixport/background_runner.rb +23 -0
  29. data/lib/ixport/configuration.rb +2 -1
  30. data/lib/ixport/engine.rb +4 -1
  31. data/lib/ixport/exporter.rb +27 -0
  32. data/lib/ixport/registrations.rb +29 -3
  33. data/lib/ixport/version.rb +1 -1
  34. data/lib/ixport.rb +2 -0
  35. data/the_local/agents/ixport-develop.md +224 -29
  36. data/the_local/agents/ixport-info.md +55 -22
  37. data/the_local/agents/ixport-install.md +63 -38
  38. data/the_local/interface.yml +36 -1
  39. metadata +18 -1
data/config/routes.rb CHANGED
@@ -1,5 +1,9 @@
1
1
  Ixport::Engine.routes.draw do
2
2
  root "imports#index"
3
+ resources :exports, only: %i[index show] do
4
+ resources :templates, only: %i[new create], controller: "export_templates"
5
+ end
6
+ resources :export_templates, only: %i[show edit update destroy]
3
7
  resources :imports, only: %i[new create show] do
4
8
  resource :mapping, only: %i[show update]
5
9
  resource :preview, only: :show
@@ -15,6 +15,9 @@ module Ixport
15
15
  def copy_migrations
16
16
  migration_template "create_ixport_imports.rb.erb", "db/migrate/create_ixport_imports.rb"
17
17
  migration_template "create_ixport_import_refusals.rb.erb", "db/migrate/create_ixport_import_refusals.rb"
18
+ migration_template "create_ixport_export_templates.rb.erb", "db/migrate/create_ixport_export_templates.rb"
19
+ migration_template "add_wallflower_task_to_ixport_imports.rb.erb", "db/migrate/add_wallflower_task_to_ixport_imports.rb"
20
+ migration_template "add_error_message_to_ixport_imports.rb.erb", "db/migrate/add_error_message_to_ixport_imports.rb"
18
21
  end
19
22
  end
20
23
  end
@@ -0,0 +1,5 @@
1
+ class AddErrorMessageToIxportImports < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ add_column :ixport_imports, :error_message, :text
4
+ end
5
+ end
@@ -0,0 +1,6 @@
1
+ class AddWallflowerTaskToIxportImports < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ add_column :ixport_imports, :wallflower_task_id, :bigint
4
+ add_index :ixport_imports, :wallflower_task_id
5
+ end
6
+ end
@@ -0,0 +1,12 @@
1
+ class CreateIxportExportTemplates < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :ixport_export_templates do |t|
4
+ t.string :key, null: false
5
+ t.string :name, null: false
6
+ t.json :columns, null: false
7
+ t.references :person, polymorphic: true, null: false
8
+ t.references :account, polymorphic: true, null: true
9
+ t.timestamps
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module Ixport
7
+ module Generators
8
+ class UpdateGenerator < Rails::Generators::Base
9
+ include ActiveRecord::Generators::Migration
10
+
11
+ source_root File.expand_path("../install/templates", __dir__)
12
+
13
+ desc "Updates Ixport: copies the migrations added since it was installed."
14
+
15
+ def copy_export_templates_migration
16
+ migration_template "create_ixport_export_templates.rb.erb", "db/migrate/create_ixport_export_templates.rb"
17
+ end
18
+
19
+ def copy_wallflower_task_migration
20
+ migration_template "add_wallflower_task_to_ixport_imports.rb.erb", "db/migrate/add_wallflower_task_to_ixport_imports.rb"
21
+ end
22
+
23
+ def copy_error_message_migration
24
+ migration_template "add_error_message_to_ixport_imports.rb.erb", "db/migrate/add_error_message_to_ixport_imports.rb"
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ixport
4
+ class ImportFailed < StandardError; end
5
+
6
+ def self.background_runner
7
+ case configuration.background_runner
8
+ when nil then nil
9
+ when :wallflower then WallflowerRunner
10
+ else configuration.background_runner.to_s.constantize
11
+ end
12
+ end
13
+
14
+ def self.run_import(import, reporter: NullReporter.new)
15
+ import.run!(reporter: reporter)
16
+ end
17
+
18
+ def self.install_background_runner!
19
+ return unless configuration.background_runner == :wallflower
20
+
21
+ Wallflower.register_kind(:ixport_import, title: "Import", runner: "Ixport::WallflowerTaskRunner")
22
+ end
23
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Ixport
4
4
  class Configuration
5
- attr_accessor :authentication_method, :current_person_method, :current_account_method, :layout, :inline_row_limit
5
+ attr_accessor :authentication_method, :current_person_method, :current_account_method, :layout, :inline_row_limit, :background_runner
6
6
 
7
7
  def initialize
8
8
  @authentication_method = :authenticate_user!
@@ -10,6 +10,7 @@ module Ixport
10
10
  @current_account_method = :current_account
11
11
  @layout = "application"
12
12
  @inline_row_limit = 1_000
13
+ @background_runner = nil
13
14
  end
14
15
  end
15
16
 
data/lib/ixport/engine.rb CHANGED
@@ -4,6 +4,9 @@ module Ixport
4
4
  class Engine < ::Rails::Engine
5
5
  isolate_namespace Ixport
6
6
 
7
- config.after_initialize { Ixport.check_registrations! }
7
+ config.after_initialize do
8
+ Ixport.check_registrations!
9
+ Ixport.install_background_runner!
10
+ end
8
11
  end
9
12
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ixport
4
+ class Exporter
5
+ Column = Struct.new(:key, :label, :through, keyword_init: true)
6
+
7
+ attr_reader :person, :account
8
+
9
+ def initialize(person:, account:)
10
+ @person = person
11
+ @account = account
12
+ end
13
+
14
+ def self.column(key, label:, through: nil)
15
+ columns << Column.new(key: key.to_sym, label: label, through: through)
16
+ end
17
+
18
+ def self.columns
19
+ @columns ||= []
20
+ end
21
+
22
+ def value(record, column)
23
+ source = column.through ? record.public_send(column.through) : record
24
+ source&.public_send(column.key)
25
+ end
26
+ end
27
+ end
@@ -1,12 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ixport
4
- Registration = Struct.new(:key, :title, :importer, keyword_init: true)
4
+ module AccessRule
5
+ def allows?(person, account)
6
+ allow.nil? || allow.call(person, account)
7
+ end
8
+ end
9
+
10
+ Registration = Struct.new(:key, :title, :importer, :allow, keyword_init: true) { include AccessRule }
11
+ ExportRegistration = Struct.new(:key, :title, :exporter, :chosen, :allow, keyword_init: true) { include AccessRule }
5
12
 
6
13
  class MissingImporter < StandardError; end
14
+ class MissingExporter < StandardError; end
7
15
 
8
- def self.register_import(key, title:, importer:)
9
- registrations[key.to_sym] = Registration.new(key: key.to_sym, title: title, importer: importer.to_s)
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)
10
18
  end
11
19
 
12
20
  def self.registration(key)
@@ -23,9 +31,27 @@ module Ixport
23
31
 
24
32
  raise MissingImporter, "Ixport import #{registration.key} names the importer #{registration.importer}, which does not exist."
25
33
  end
34
+ export_registrations.each_value do |registration|
35
+ next if registration.exporter.safe_constantize
36
+
37
+ raise MissingExporter, "Ixport export #{registration.key} names the exporter #{registration.exporter}, which does not exist."
38
+ end
39
+ end
40
+
41
+ def self.register_export(key, title:, exporter:, chosen: false, allow: nil)
42
+ export_registrations[key.to_sym] = ExportRegistration.new(key: key.to_sym, title: title, exporter: exporter.to_s, chosen: chosen, allow: allow)
43
+ end
44
+
45
+ def self.export_registration(key)
46
+ export_registrations.fetch(key.to_sym)
47
+ end
48
+
49
+ def self.export_registrations
50
+ @export_registrations ||= {}
26
51
  end
27
52
 
28
53
  def self.reset_registrations!
29
54
  @registrations = {}
55
+ @export_registrations = {}
30
56
  end
31
57
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Ixport
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/ixport.rb CHANGED
@@ -3,6 +3,8 @@ require "ixport/version"
3
3
  require "ixport/configuration"
4
4
  require "ixport/registrations"
5
5
  require "ixport/importer"
6
+ require "ixport/exporter"
7
+ require "ixport/background_runner"
6
8
  require "ixport/engine"
7
9
 
8
10
  module Ixport
@@ -1,38 +1,59 @@
1
1
  ---
2
2
  name: ixport-develop
3
- description: Use PROACTIVELY for setting up CSV imports in a Rails app that has ixport — registering a mapped import, writing its importer class, declaring the fields a person maps a CSV's columns to, choosing the field rows are matched on, scoping the records an import may add or update to the signed-in person or account, linking to an import's upload page, explaining the upload, column-matching, preview and result pages and their messages, explaining what running an import saves and refuses, and fixing a boot that fails with Ixport::MissingImporter — MUST BE USED instead of hand-writing a CSV upload controller, a column-mapping form, a row-matching loop, an import preview or a loop that saves imported rows.
3
+ description: Use PROACTIVELY for setting up CSV imports and exports in a Rails app that has ixport — registering a mapped import, writing its importer class, declaring the fields a person maps a CSV's columns to, choosing the field rows are matched on, scoping the records an import may add or update to the signed-in person or account, limiting which people may use an import or export with an access rule such as the app's own Pundit policy, linking to an import's upload page, explaining the upload, column-matching, preview and result pages and their messages, explaining the list of a person's past imports on the imports page, explaining what running an import saves and refuses, explaining what a person sees when an import fails partway and which rows it keeps, running imports in a background job through the app's own runner class, explaining what a person sees while an import runs in the background, registering a fixed export or a chosen export, writing its exporter class, declaring its columns including columns taken from a related record, computing a column's value, scoping the records an export may hold, letting an account save named templates of picked columns for an export, explaining the new, edit and delete template pages and what a template's download holds, linking to the exports page, one export's CSV download or one template's download, making the app's own import job fail with Ixport::ImportFailed, and fixing a boot that fails with Ixport::MissingImporter or Ixport::MissingExporter — MUST BE USED instead of hand-writing a CSV upload controller, a column-mapping form, a row-matching loop, an import preview, a loop that saves imported rows, a background job that saves imported rows, a rescue that records why an import stopped, a permission check in front of an import or export page, a CSV download action, a loop that writes records to a CSV, or a model and form for saved column picks.
4
4
  tools: Bash, Read, Write, Edit, Grep
5
- scope: CSV import and export — a mounted engine where a signed-in person sees the mapped imports the host app registers, each backed by an importer class the host writes
5
+ scope: CSV import and export — a mounted engine where a signed-in person sees the mapped imports and the exports the host app registers, each import backed by an importer class and each export by an exporter class the host writes, where an export is either fixed or chosen, and a chosen export lets the account save named templates of picked columns
6
6
  ---
7
7
 
8
- This local adds mapped imports to a Rails app that already has ixport installed by following the steps below exactly. It writes one importer class and one registration line per import, and it invents no method, option, route or file that is not listed here.
8
+ This local adds mapped imports, fixed exports and chosen exports to a Rails app that already has ixport installed by following the steps below exactly. It writes one importer or exporter class and one registration line per import or export, and it invents no method, option, route or file that is not listed here.
9
9
 
10
10
  ## What ixport is
11
11
 
12
- ixport is a Rails engine that lets a signed-in person import a host app's records from CSV files. The app registers each kind of CSV a person can import, called a mapped import, and writes an importer class that names the fields the CSV's columns map to, the one field rows are matched on, and the records a row may add to or update. A person picks an import, uploads a CSV file for it, matches each of the file's columns to one of the importer's fields, sees a preview of how many rows will add a record, update one or be refused, runs the import to save those rows, and then sees how many rows were added and updated and each refused row. Fire this local when a developer wants people to load one of the app's records from a spreadsheet, wants to link to an import's upload page, asks what the upload, column-matching, preview or result pages do, asks what running an import saves, or when the app fails to boot with `Ixport::MissingImporter`.
12
+ ixport is a Rails engine that lets a signed-in person import a host app's records from CSV files and download them as CSV files. The app registers each kind of CSV a person can import, called a mapped import, and writes an importer class that names the fields the CSV's columns map to, the one field rows are matched on, and the records a row may add to or update. A person picks an import, uploads a CSV file for it, matches each of the file's columns to one of the importer's fields, sees a preview of how many rows will add a record, update one or be refused, runs the import to save those rows, and then sees how many rows were added and updated and each refused row. When the run stops on an error, the import is marked failed, keeps the rows it saved before stopping, and its page shows the error. An import runs within the request by default, or, when the app is set up for it, in the background through wallflower or through a runner class the app writes. The imports page also lists the person's own past imports in the current account, each opening at the step it has reached. The app also registers each CSV a person can download, and writes an exporter class that names the columns the file can hold, in order, and the records it holds one row each for. An export is either fixed or chosen. A fixed export always holds every declared column, and a person downloads it from the exports page. A chosen export lets people in the current account save named templates, each a picked list of the exporter's columns in a picked order, and a person downloads a template from the exports page to get a file holding only those columns. Fire this local when a developer wants people to load one of the app's records from a spreadsheet or download them as one, wants people to pick which columns an export holds, wants to link to an import's upload page, the exports page, an export's download or a template's download, asks what the upload, column-matching, preview, result, exports or template pages do, asks what the list of past imports shows, asks what running an import saves, what happens when a run fails, or what an export's or template's file holds, wants imports run in a background job of the app's own, or when the app fails to boot with `Ixport::MissingImporter` or `Ixport::MissingExporter`.
13
13
 
14
14
  ## Interface
15
15
 
16
- - `Ixport.register_import` — `Ixport.register_import(key, title:, importer:)` registers one mapped import under a key, with the title a person sees and the name of its importer class.
16
+ - `Ixport.register_import` — `Ixport.register_import(key, title:, importer:, allow: nil)` registers one mapped import under a key, with the title a person sees, the name of its importer class, and optionally an access rule that decides which people may use it.
17
17
  - `Ixport::Importer` — the base class every importer inherits from.
18
18
  - `field` — `field key, label:` is called in an importer's class body and declares one field a CSV column can be mapped to, with the label a person sees.
19
19
  - `match_on` — `match_on key` is called in an importer's class body and names the one declared field a row is matched to an existing record on.
20
- - `records` — an instance method every importer defines, returning the Active Record relation a row may update a record from and adds a new record to.
21
- - `person` — returns the person the import belongs to, the one who uploaded the file, for use inside `records`.
22
- - `account` — returns the account the import belongs to, the one current when the file was uploaded, for use inside `records`, and is `nil` when the app has no current account.
20
+ - `records` — an instance method every importer and every exporter defines: in an importer it returns the Active Record relation a row may update a record from and adds a new record to, and in an exporter it returns the records the CSV holds, one row each, in the order returned.
21
+ - `person` — returns the signed-in person, for use inside `records` and `value`: in an importer the person who uploaded the file, in an exporter the person downloading it.
22
+ - `account` — returns the current account, for use inside `records` and `value`: in an importer the account current when the file was uploaded, in an exporter the account current when the file is downloaded, and `nil` when the app has no current account.
23
23
  - `Ixport::MissingImporter` — the error raised when the app boots if a registered import names an importer class that does not exist.
24
- - `GET /` — the engine's root page, at the path the engine is mounted under, which lists every registered import by its title, each linking to that import's upload page.
24
+ - `Ixport::MissingExporter` — the error raised when the app boots if a registered export names an exporter class that does not exist.
25
+ - `Ixport::ImportFailed` — the error a background run is failed with when its import stopped on an error, carrying the import's error message: wallflower's task raises it, and the app's own job may raise it.
26
+ - `Ixport.run_import` — `Ixport.run_import(import)` or `Ixport.run_import(import, reporter: reporter)` runs one import that a runner was handed, as the signed-in person and in the account it was uploaded with, saving and refusing its rows the same way as a run within the request, marking the import failed rather than raising when the run stops on an error, and optionally reporting each row to `reporter`.
27
+ - `enqueue` — `enqueue(import)` is the class method the app's own runner class defines, which ixport calls with each import a person runs, once the import is marked running, so the runner can arrange for `Ixport.run_import` to run it later.
28
+ - `GET /` — the engine's root page, at the path the engine is mounted under, which lists every registered import by its title, each linking to that import's upload page, and then lists the signed-in person's past imports in the current account, newest first, each linking to the step it has reached.
25
29
  - `GET /imports/new` — the upload page for one import, given the import's registered key as the `key` query parameter, which shows the import's title, a CSV file field and an Upload button.
26
30
  - `POST /imports` — takes the `key` and `file` parameters, stores the uploaded file as an import belonging to the signed-in person and the current account, and redirects to that import's column-matching page.
27
31
  - `GET /imports/:import_id/mapping` — the column-matching page, titled "Match columns", which lists each column header from the file's first row beside a select of the importer's field labels and a "Skip" choice.
28
32
  - `PATCH /imports/:import_id/mapping` — saves which field each column maps to and redirects to the import's preview page.
29
- - `GET /imports/:import_id/preview` — the preview page, titled "Preview", which shows how many rows will add a record, update one or be refused, lists each updating row and each refused row by its row number, saves nothing, and offers a "Run import" button, and which redirects to the import's result page once the import has run.
30
- - `POST /imports/:import_id/run` — saves every row that adds or updates a record, records every refused row with its reason, marks the import finished, and redirects to the import's result page, and does nothing but redirect when the import has already run.
31
- - `GET /imports/:id` — the result page, titled "Import finished", which shows how many rows the run added and updated and lists each refused row by its row number with its reason, and which redirects to the import's preview page when the import has not run.
33
+ - `GET /imports/:import_id/preview` — the preview page, titled "Preview", which shows how many rows will add a record, update one or be refused, lists each updating row and each refused row by its row number, saves nothing, and offers a "Run import" button, and which redirects to the import's result page once the import has finished or failed, or while it is running in the background.
34
+ - `POST /imports/:import_id/run` — with no background runner, saves every row that adds or updates a record, records every refused row with its reason, marks the import finished, or failed when the run stops on an error, and redirects to the import's result page, and does nothing but redirect when the import has already finished or failed; with a background runner, marks a pending import running, hands it to the runner, and redirects to the wallflower task's page or, for the app's own runner, to the import's result page, and hands nothing to the runner and redirects to the import's result page when the import is already running, finished or failed.
35
+ - `GET /imports/:id` — the result page, titled "Import finished", which shows how many rows the run added and updated and lists each refused row by its row number with its reason, titled "Import running" with the message "This import is running." while a background run is under way, titled "Import failed" with the error the run stopped on and the counts of rows saved before it stopped when the run failed, and which redirects to the import's preview page when the import has neither run nor started running.
36
+ - `Ixport.register_export` — `Ixport.register_export(key, title:, exporter:, chosen: false, allow: nil)` registers one export under a key, with the title a person sees and the name of its exporter class, as a fixed export by default or as a chosen export when `chosen: true`, and optionally an access rule that decides which people may use it.
37
+ - `Ixport::Exporter` — the base class every exporter inherits from.
38
+ - `column` — `column key, label:, through: nil` is called in an exporter's class body and declares one column of the CSV, with the header a person sees, read from the record's `key` attribute, or from the `key` attribute of the related record named by `through:`.
39
+ - `value` — `value(record, column)` is the instance method that returns one cell of the CSV for one record and one declared column, and an exporter may override it to compute a cell.
40
+ - `GET /exports` — the exports page, titled "Exports", which lists every registered export by its title: a fixed export links to its download, and a chosen export lists the current account's templates for it with a "New template" button.
41
+ - `GET /exports/:id` — downloads the CSV of the export registered under the key given as `:id`, holding every declared column, as a file named after that key.
42
+ - `GET /exports/:export_id/templates/new` — the new template page for the chosen export registered under the key given as `:export_id`, which shows a "Template name" field and one column select per declared column.
43
+ - `POST /exports/:export_id/templates` — takes the `name` and `columns[]` parameters, saves a template for that chosen export belonging to the signed-in person and the current account, and redirects to the exports page.
44
+ - `GET /export_templates/:id` — downloads the CSV of one template, holding only its picked columns in its picked order, as a file named after the template's name.
45
+ - `GET /export_templates/:id/edit` — the edit template page, which shows the template's name and picked columns in the same fields as the new template page, and a Delete action.
46
+ - `PATCH /export_templates/:id` — takes the `name` and `columns[]` parameters, replaces the template's name and picked columns, and redirects to the exports page.
47
+ - `DELETE /export_templates/:id` — deletes the template and redirects to the exports page.
32
48
 
33
49
  ## How to use it
34
50
 
35
- 1. Check that ixport is installed: `bin/rails routes` lists a path served by ixport. If it does not, stop and hand the setup to the `ixport-install` local before going on.
51
+ Steps 1 to 24 add a mapped import, and steps 19 to 23 of those cover running imports in the background. Steps 25 to 37 add an export, fixed or chosen. Steps 38 to 44 cover the templates of a chosen export. Do step 1 first in every case.
52
+
53
+ 1. Check that ixport is installed: `bin/rails routes` lists a path served by ixport. If it does not, stop and hand the setup to the `ixport-install` local before going on. For any import, also check that `db/migrate/` has an `add_error_message_to_ixport_imports` migration and that it has run, because a run that stops on an error writes the column it adds, and without it the run raises an error naming the missing column. If it does not, hand the update to the `ixport-install` local before going on. To run imports in the background, also check that `db/migrate/` has an `add_wallflower_task_to_ixport_imports` migration and that it has run, because pressing "Run import" with any background runner reads the column it adds, including with the app's own runner. If it does not, hand the update to the `ixport-install` local before going on. For a chosen export, also check that `db/migrate/` has a `create_ixport_export_templates` migration and that it has run. If it does not, hand the update to the `ixport-install` local before going on.
54
+
55
+ ### A mapped import
56
+
36
57
  2. Ask the developer which of the app's records the import loads, such as products or customers. Do not pick one.
37
58
  3. Ask the developer which attributes of that record a person may import, and the label a person should see for each, such as "SKU" for `sku` or "Price" for `price_cents`. Do not decide which attributes are importable.
38
59
  4. Ask the developer which of those fields identifies an existing record, such as the SKU or the email. Do not pick the match field.
@@ -62,16 +83,34 @@ ixport is a Rails engine that lets a signed-in person import a host app's record
62
83
  Ixport.register_import :products, title: "Products", importer: "ProductImporter"
63
84
  ```
64
85
  Pass `importer:` the class name as a string, never the class itself, so the initializer does not load the class before the app's code is ready.
86
+
87
+ Ask the developer whether every signed-in person may use this import, or only some. Do not decide who may. When only some may, pass `allow:` a rule that takes the signed-in person and the current account and returns true for a person who may use it, such as a call to the app's own Pundit policy:
88
+ ```ruby
89
+ Ixport.register_import :products, title: "Products", importer: "ProductImporter",
90
+ allow: ->(person, account) { ProductImportPolicy.new(person, account).create? }
91
+ ```
92
+ - A registration with no `allow:` is open to every signed-in person.
93
+ - The rule is called on every request to the import's pages and on every visit to the engine's root page, so it must not change anything.
94
+ - `account` is `nil` when the app has no current account, so the rule must handle that when it can happen.
95
+ - A person the rule refuses does not see the import on the engine's root page, does not see their own past imports of it under "Your imports", and gets a not found response from its upload, column-matching, preview, run and result pages, including for imports they uploaded before the rule refused them.
96
+ - The rule is checked within the person's request only. A background run already handed to a runner still runs to the end.
65
97
  9. Start the app. If it fails to boot with `Ixport::MissingImporter`, the message names the import key and the class name that does not exist. Fix the class name in the registration, or the class name or file name of the importer, so the two match.
66
- 10. Visit the path the engine is mounted under while signed in. The page shows the heading "Imports" and lists each registered import by its title, in the order the imports were registered. With no import registered it shows "No imports are set up yet."
98
+ 10. Visit the path the engine is mounted under while signed in. The page shows the heading "Imports" and lists each registered import the signed-in person is allowed to use by its title, in the order the imports were registered. With no import registered, or none the person is allowed to use, it shows "No imports are set up yet." Below that, once the signed-in person has uploaded any file, a "Your imports" section lists their past imports:
99
+ - It lists only imports uploaded by the signed-in person in the current account, newest first, of imports that are still registered and that the person is allowed to use. Imports by other people, or by the same person in another account, are not listed.
100
+ - Each shows the import's title, the uploaded file's name, its status, "Pending", "Running", "Finished" or "Failed", and the date it was uploaded, such as "Oct 10, 2026".
101
+ - A finished import also shows its "Added", "Updated" and "Refused" counts. A pending, running or failed import shows no counts.
102
+ - Each title links to the step the import has reached: the "Import finished" page once it has run, the "Preview" page once its mapping is saved with at least one column mapped, and otherwise the "Match columns" page. A running import's link leads to its "Import running" page, and a failed import's link leads to its "Import failed" page.
103
+ - When the person has no past import, the section is not shown.
67
104
  11. When the developer wants a link to one import's upload page from the app's own pages, ask where the link goes. Build its URL with the engine's route helper and the import's registered key:
68
105
  ```erb
69
106
  <%= link_to "Import products", ixport.new_import_path(key: :products) %>
70
107
  ```
71
- The key must be one that is registered. An unregistered key gets a not found response.
108
+ The key must be one that is registered. An unregistered key, or one whose rule refuses the person, gets a not found response. When the import has an `allow:` rule, show the link only to people the same rule allows.
72
109
  12. On the upload page a person chooses a CSV file and presses Upload. The upload is refused, and the upload page is shown again with status 422 and one of these messages, when:
110
+ - the file is not UTF-8 text: "This file is not UTF-8 text. Save it as UTF-8 and upload it again."
111
+ - the file is not valid CSV, such as a quote left open: "This file could not be read past line N. Check the quotes on that line."
73
112
  - the file has no header row: "This file has no header row."
74
- - the file has more data rows than the inline row limit, not counting the header row: "This file has N rows, and the most an import can have is L." The limit is set by `ixport-install`.
113
+ - the file has more data rows than the inline row limit, not counting the header row: "This file has N rows, and the most an import can have is L." The limit is set by `ixport-install`, and it applies only when imports run within the request. With a background runner, a file of any length is accepted.
75
114
  13. After an upload is accepted, the person is on the "Match columns" page. Each column header from the file's first row is listed with a select of the importer's field labels. "Skip" leaves that column unmapped. Pressing "Save mapping" saves the choices and takes the person to the "Preview" page.
76
115
  14. The preview decides each data row's outcome this way:
77
116
  - The row's value for the match field is looked up in `records`, with one query for the whole file.
@@ -81,24 +120,180 @@ ixport is a Rails engine that lets a signed-in person import a host app's record
81
120
  - A row whose added or updated record is not valid will be refused, with the record's own validation messages as the reason.
82
121
  15. The preview page shows three counts, "Will add", "Will update" and "Will be refused". Under them it lists each row that will update a record under "Will update", and each refused row with its reason under "Will be refused", each by its row number in the file, where the header row is row 1. The refusal reasons are the record's validation messages, so tell the developer that the record's validations decide which rows are refused and what a person reads.
83
122
  16. Viewing the preview adds and changes no record. Its back link returns the person to the "Match columns" page to change the mapping.
84
- 17. Pressing "Run import" on the preview page runs the import within the same request, with no background job:
123
+ 17. When the app has no background runner, pressing "Run import" on the preview page runs the import within the same request, with no background job. Steps 19 to 23 cover an app with a background runner, where rows are saved and refused by the same rules. The run:
85
124
  - Every row's outcome is decided again by the rules in step 14, against the records as they are at that moment, so the result can differ from the preview when records changed in between.
86
125
  - Every row that adds or updates a record is saved with `save!`, so the record's callbacks run as for any other save.
87
126
  - Every refused row is recorded with its row number and reason, and no record is saved for it.
88
- - The whole run happens in one database transaction. When any save raises, such as on a database constraint the record's validations do not cover, nothing from the run is saved and the import stays unrun. Tell the developer to cover every database constraint on the record with a validation, so a bad row is refused rather than stopping the whole run.
89
- - An import runs once. A second run request for the same import, including one sent at the same time as the first, adds and changes nothing and redirects to the result page.
90
- 18. After the run, the person is on the "Import finished" page. It shows two counts, "Added" and "Updated", and, when any row was refused, lists each refused row under "Refused" by its row number with its reason. Returning to this page later shows the same counts and refused rows. Visiting this page for an import that has not run redirects to its preview page. Once an import has run, its preview page redirects to this page, so it offers no "Run import" button.
91
- 19. An import's column-matching, preview, run and result pages are visible only to the person who uploaded it, in the account it was uploaded in. Anyone else, and the same person in another account, gets a not found response.
127
+ - Rows are saved in file order, and each row's save is undone on its own when it raises.
128
+ - When anything raises during the run, such as a save hitting a database constraint the record's validations do not cover, the run stops at that row. The rows saved and refused before it stay saved, the rows after it are neither saved nor refused, and the import is marked failed with the error's message and the counts of rows added and updated before it stopped.
129
+ - The error is reported through Rails' error reporter, so it reaches whatever error service the app has subscribed there. It is not raised to the person's request.
130
+ - Tell the developer to cover every database constraint on the record with a validation, so a bad row is refused rather than stopping the run.
131
+ - An import runs once. A second run request for the same import, including one sent at the same time as the first, adds and changes nothing and redirects to the result page. A failed import is not run again, so the rows after the one it stopped on can only be loaded by uploading them as a new import.
132
+ 18. After the run, the person is on the result page:
133
+ - When the run finished, the page is titled "Import finished". It shows two counts, "Added" and "Updated", and, when any row was refused, lists each refused row under "Refused" by its row number with its reason.
134
+ - When the run failed, the page is titled "Import failed". It shows the error's message, the line "Rows saved before it stopped stay saved.", the "Added" and "Updated" counts of rows saved before it stopped, and each row refused before it stopped.
135
+ - The error's message is the exception's own message, such as a database's constraint error, so tell the developer that a person reads it as written.
136
+ - Returning to this page later shows the same page. Visiting it for an import that has neither run nor started running redirects to its preview page. Once an import has finished or failed, its preview page redirects to this page, so it offers no "Run import" button.
137
+
138
+ ### Running imports in the background
139
+
140
+ Whether imports run within the request, through wallflower, or through the app's own runner class is set by `ixport-install`. Steps 19 to 23 apply only when one of the two background runners is set.
141
+
142
+ 19. With a background runner, pressing "Run import" marks the import running and hands it to the runner, and saves no row within the request:
143
+ - With wallflower, the person is taken to the wallflower task's page, which shows the run's progress, the rows it changed and refused, and a link to the import's result page once the run ends. When the run failed, the task fails with `Ixport::ImportFailed` carrying the import's error message, and still links to the import's "Import failed" page.
144
+ - With the app's own runner, the person is taken to the import's result page, titled "Import running", with the message "This import is running." and no counts. Once the run ends, the same page is titled "Import finished" or "Import failed" and shows what step 18 describes.
145
+ - While the import runs, its preview page redirects to its "Import running" page, so it offers no "Run import" button, and the imports page lists it as "Running" with a link that leads to that page.
146
+ - An import is handed to the runner once. A second run request for an import that is already running, finished or failed, such as one sent from a preview page left open, hands nothing to the runner and redirects to the import's result page.
147
+ 20. For the app's own runner, ask the developer which background job system the app uses and which queue an import's job goes on. Do not pick either.
148
+ 21. Write the runner class under the class name `ixport-install` set, with a class method `enqueue` that takes the import and starts a job, and write the job so it calls `Ixport.run_import` with the import:
149
+ ```ruby
150
+ class ImportRunner
151
+ def self.enqueue(import)
152
+ RunImportJob.perform_later(import)
153
+ end
154
+ end
155
+ ```
156
+ ```ruby
157
+ class RunImportJob < ApplicationJob
158
+ def perform(import)
159
+ Ixport.run_import(import)
160
+ end
161
+ end
162
+ ```
163
+ - Pass the import itself to the job, as above, never its id, because the job gets the import back only from what it was passed.
164
+ - `enqueue` is called within the person's request, so it must start the job and return, never run the import itself.
165
+ - The value `enqueue` returns is not used.
166
+ 22. `Ixport.run_import` runs the import by the rules in steps 14 and 17:
167
+ - It builds the importer with the person who uploaded the file and the account current at that upload, so the job needs no signed-in person and `records` is scoped as it was for the preview.
168
+ - It adds and changes nothing when the import has already finished or failed.
169
+ - When the run stops on an error, it keeps the rows saved before it, marks the import failed, reports the error through Rails' error reporter, and returns without raising, as in step 17. The job therefore succeeds, and a retry of it would change nothing.
170
+ - Ask the developer whether a failed import should also fail the job, such as to show it in the job system's failed list. If it should, raise `Ixport::ImportFailed` with the import's error message after the run, and turn off retries for it, since a retry changes nothing:
171
+ ```ruby
172
+ def perform(import)
173
+ Ixport.run_import(import)
174
+ raise Ixport::ImportFailed, import.error_message if import.failed?
175
+ end
176
+ ```
177
+ - When `enqueue` raises, or the job never runs, the import stays running and its page keeps showing "This import is running.". Tell the developer this, and ask how they want such imports noticed.
178
+ 23. When the developer wants to show a run's progress somewhere of their own, pass `reporter:` an object that answers all three of these, and ask the developer where the progress is shown:
179
+ - `set_total(total)` is called once, before the first row, with the number of data rows.
180
+ - `refuse(label:, reason:)` is called for each refused row, with a label such as "Row 4" and the refusal reason.
181
+ - `advance` is called once after each row, saved or refused.
182
+ 24. An import's column-matching, preview, run and result pages are visible only to the person who uploaded it, in the account it was uploaded in, while the import's `allow:` rule allows them. Anyone else, the same person in another account, and the same person once the rule refuses them, gets a not found response.
183
+
184
+ ### An export
185
+
186
+ 25. Ask the developer which of the app's records the export holds, such as products or customers. Do not pick one.
187
+ 26. Ask the developer which columns the file can hold, in order, and the header a person should see for each, such as "SKU" for `sku`. For each column, ask whether it is an attribute of the record itself, an attribute of a related record such as the product's account name, or a value computed from the record. Do not decide which columns are exported.
188
+ 27. Ask the developer which records the file holds, such as the current account's products, and in what order its rows go. Do not pick the scope or the order. An export that reaches every row of the table lets one account download another account's records.
189
+ 28. Ask the developer whether the export is fixed or chosen. Do not pick one.
190
+ - Fixed: every download holds every column from step 26, in the declared order.
191
+ - Chosen: people in the current account save named templates that each pick some of those columns in an order of their own, and each template downloads only its picked columns.
192
+ 29. Write the exporter class in `app/exporters/`, named after the record, inheriting from `Ixport::Exporter`. A fixed export and a chosen export use the same kind of class:
193
+ ```ruby
194
+ class ProductExporter < Ixport::Exporter
195
+ column :sku, label: "SKU"
196
+ column :price_cents, label: "Price"
197
+ column :name, label: "Account", through: :account
198
+
199
+ def records
200
+ account.products.includes(:account).order(:sku)
201
+ end
202
+ end
203
+ ```
204
+ - Call `column` once per column from step 26, in the order the columns appear in the file. `label:` is required and is that column's header in the file's first row.
205
+ - For a chosen export, the labels are the choices in each column select on the template pages, in the declared order.
206
+ - Each column's key must be unique within the exporter, because a template records its picked columns by key.
207
+ - For an attribute of the record itself, the key is the attribute's name as a symbol.
208
+ - For an attribute of a related record, pass `through:` the name of the association on the record, and the key is the attribute's name on the related record. A record whose related record is `nil` gets an empty cell in that column.
209
+ - Define `records` to return the records from step 27, built from `person` or `account`, in the row order from step 27. The file has one row per record, in the order `records` returns them.
210
+ - When any column uses `through:`, add `includes` for each association it names to `records`, so the download does not run one query per row.
211
+ - `account` is `nil` when the app has no current account, so a `records` that calls a method on `account` fails in that case. Ask the developer whether that can happen in this app.
212
+ - Never define `initialize` in an exporter. ixport builds the exporter with the signed-in person and the current account.
213
+ 30. For a computed column from step 26, declare it with `column` under a key of your choosing and override `value` to return it, calling `super` for every other column:
214
+ ```ruby
215
+ def value(record, column)
216
+ return record.price_cents / 100.0 if column.key == :price
217
+
218
+ super
219
+ end
220
+ ```
221
+ - `column.key` is the column's key as a symbol, and `column.label` is its header.
222
+ - Ask the developer how the computed value is written, such as how many decimal places, rather than picking a format.
223
+ - A template's download writes its cells through the same `value`, so a computed column is computed the same way in every template that picks it.
224
+ 31. Every cell is written as the value's `to_s`, and a `nil` value is an empty cell. When the developer needs a date, time or amount written a particular way, compute that column in `value` as in step 30.
225
+ 32. Ask the developer what key and title the export gets. The key is a short symbol that identifies the export, such as `:products`, and is also the fixed export's downloaded file name, such as `products.csv`. The title is what a person sees on the exports page, such as "Products".
226
+ 33. Add the registration to the end of `config/initializers/ixport.rb`, and create the file if it does not exist. For a fixed export:
227
+ ```ruby
228
+ Ixport.register_export :products, title: "Products", exporter: "ProductExporter"
229
+ ```
230
+ For a chosen export, add `chosen: true`:
231
+ ```ruby
232
+ Ixport.register_export :products, title: "Products", exporter: "ProductExporter", chosen: true
233
+ ```
234
+ Pass `exporter:` the class name as a string, never the class itself, so the initializer does not load the class before the app's code is ready.
235
+
236
+ Ask the developer whether every signed-in person may use this export, or only some. Do not decide who may. When only some may, pass `allow:` a rule that takes the signed-in person and the current account and returns true for a person who may use it, the same way as an import's rule in step 8:
237
+ ```ruby
238
+ Ixport.register_export :products, title: "Products", exporter: "ProductExporter", chosen: true,
239
+ allow: ->(person, account) { ProductExportPolicy.new(person, account).show? }
240
+ ```
241
+ - A registration with no `allow:` is open to every signed-in person.
242
+ - A person the rule refuses does not see the export on the exports page, and gets a not found response from its download and, for a chosen export, from its new template page, its template creation, and every one of its templates' download, edit, update and delete actions.
243
+ - The rule covers the export as a whole. Every template of a chosen export is allowed or refused with it, and a template cannot carry a rule of its own.
244
+ 34. Start the app. If it fails to boot with `Ixport::MissingExporter`, the message names the export key and the class name that does not exist. Fix the class name in the registration, or the class name or file name of the exporter, so the two match.
245
+ 35. Visit `exports` under the path the engine is mounted under while signed in. The page shows the heading "Exports" and lists each registered export the signed-in person is allowed to use by its title, in the order the exports were registered. With no export registered, or none the person is allowed to use, it shows "No exports are set up yet." The engine's root page lists imports only and does not link to this page.
246
+ - A fixed export's title links to its download.
247
+ - A chosen export's title is not a link. Under it are the current account's templates for that export, ordered by name, each name linking to the template's download beside an "Edit" link, and then a "New template" button.
248
+ 36. When the developer wants a link to the exports page or to one fixed export's download from the app's own pages, ask where the link goes. Build its URL with the engine's route helpers and the export's registered key:
249
+ ```erb
250
+ <%= link_to "Exports", ixport.exports_path %>
251
+ <%= link_to "Download products", ixport.export_path(:products) %>
252
+ ```
253
+ The key must be one that is registered. An unregistered key, or one whose rule refuses the person, gets a not found response. When the export has an `allow:` rule, show the link only to people the same rule allows. `export_path` also downloads a chosen export with every declared column, although the exports page does not link to it.
254
+ 37. A download builds the whole file within the same request, with no background job and no row limit, from the records `records` returns for the person downloading it, in the account current at that moment. This holds for a template's download too. Tell the developer that an export over a very large table makes that request slow.
255
+
256
+ ### The templates of a chosen export
257
+
258
+ 38. Pressing "New template" on the exports page opens the page titled "New <export title> template", with a back link to the exports page. It shows a required "Template name" field and one select per declared column, labelled "Column 1", "Column 2" and so on. Each select offers every column's label and "None". Pressing "Save template" saves the template and returns the person to the exports page.
259
+ 39. The picked columns are saved in this way:
260
+ - They are kept in the order of the selects, so "Column 1" is the file's first column.
261
+ - A select left at "None" adds nothing.
262
+ - A column picked in more than one select is kept once, at its first position.
263
+ - A template with no picked column produces a file with an empty header row and one empty row per record.
264
+ 40. A template belongs to the current account. Every person in that account whom the export's `allow:` rule allows sees it on the exports page and can download, edit or delete it. A person in another account, or one the rule refuses, gets a not found response. When the app has no current account, every template saved with no account is shared by every signed-in person with no account. Tell the developer this, and ask whether it is what they want.
265
+ 41. A template's name link downloads a CSV holding only its picked columns, in its picked order, with each column's label as the header and one row per record from `records`. The file is named after the template's name, lower-cased with spaces and punctuation replaced by hyphens, such as `price-list.csv` for "Price list".
266
+ 42. Pressing "Edit" opens the page titled "Edit <template name>", with the export's title under it and a back link to the exports page. It shows the same fields as the new template page, filled with the template's name and picked columns. Pressing "Save template" replaces the name and picked columns by the rules in step 39 and returns the person to the exports page.
267
+ 43. The edit page's action menu has a Delete item. It deletes the template and returns the person to the exports page.
268
+ 44. When the developer wants a link to one template's download or edit page from the app's own pages, ask where the link goes, and build it with the engine's route helpers and the template:
269
+ ```erb
270
+ <%= link_to template.name, ixport.export_template_path(template) %>
271
+ <%= link_to "Edit", ixport.edit_export_template_path(template) %>
272
+ <%= link_to "New template", ixport.new_export_template_path(:products) %>
273
+ ```
274
+ `new_export_template_path` takes the chosen export's registered key. An unregistered key, the key of a fixed export, or the key of an export whose rule refuses the person, gets a not found response.
92
275
 
93
276
  ## Conventions
94
277
 
95
- - Every registration names an importer class that exists. The app refuses to boot otherwise, so never register an import before its importer class is written.
96
- - Each key is registered once. Registering the same key a second time replaces the first registration.
278
+ - Every registration names an importer or exporter class that exists. The app refuses to boot otherwise, so never register an import or export before its class is written.
279
+ - Each import key is registered once, and each export key is registered once. Registering the same key a second time replaces the first registration.
97
280
  - Every importer declares `match_on` and defines `records`.
281
+ - Every exporter declares at least one `column` and defines `records`.
98
282
  - `records` is always scoped to the `person` or `account` the developer chose, never left to the whole table without the developer saying so.
99
- - An importer inherits from `Ixport::Importer` directly and declares all of its own fields and its own match field. Neither is inherited by a class that inherits from another importer.
100
- - When a record's attribute is renamed or removed, update the matching `field` line and any `match_on` that names it in the same change.
101
- - When an importer class is renamed, update its `importer:` string in `config/initializers/ixport.rb` in the same change.
102
- - When an import's key is changed, update every link to its upload page in the same change.
103
- - Never write an upload form, a column-matching form, a preview page, a run action, a result page or a controller for them in the app. The engine serves all of them.
104
- - Installing ixport, mounting the engine, the tables an import and its refused rows are stored in, file storage, sign-in, how the current person and account are found, and the inline row limit are out of scope for this local and belong to `ixport-install`.
283
+ - An importer inherits from `Ixport::Importer` directly and declares all of its own fields and its own match field. An exporter inherits from `Ixport::Exporter` directly and declares all of its own columns. None of these is inherited by a class that inherits from another importer or exporter.
284
+ - An exporter that overrides `value` calls `super` for every column it does not compute.
285
+ - When a record's attribute or association is renamed or removed, update the matching `field`, `match_on` and `column` lines in the same change.
286
+ - When an importer or exporter class is renamed, update its `importer:` or `exporter:` string in `config/initializers/ixport.rb` in the same change.
287
+ - When an import's or export's key is changed, update every link to its upload page or download in the same change.
288
+ - Past imports record their import's key. Before changing an import's key or removing its registration, tell the developer that past imports under the old key drop off "Your imports" on the engine's root page, and that opening one of their pages raises an error, and ask what should happen to those imports.
289
+ - Who may use an import or export is decided only by its `allow:` rule. Never write a before action, a policy check or a redirect in front of the engine's pages.
290
+ - When an import or export has an `allow:` rule, every link to it from the app's own pages is shown only to people the same rule allows.
291
+ - Saved templates record a chosen export's key and its picked column keys. Before changing a chosen export's key, removing its registration, or changing it to fixed, ask the developer what happens to the templates already saved for it:
292
+ - A template whose export key is no longer registered no longer shows on the exports page, and opening its download or edit page raises an error.
293
+ - A template whose export is changed to fixed no longer shows on the exports page, and its download and edit links still work.
294
+ - When a chosen export's column key is renamed or removed, saved templates that picked it drop that column from their downloads without saying so. Tell the developer before making that change.
295
+ - Never write an upload form, a column-matching form, a preview page, a run action, a result page, an exports page, a CSV download action, a template form or model, or a controller for them in the app. The engine serves all of them.
296
+ - The app's own runner only starts a job, and the job only calls `Ixport.run_import`. Never write a job that reads the file, matches rows or saves records itself.
297
+ - When the app's own runner class is renamed, tell the developer that the class name `ixport-install` set must be changed in the same change.
298
+ - Never wrap `Ixport.run_import` in a rescue, a transaction or a retry of the app's own. It records its own failure, keeps the rows saved before it, and reports the error.
299
+ - Installing ixport, running the update generator for the export templates table, the background runner's column or the failed import's error column, subscribing an error service to Rails' error reporter, mounting the engine, the tables an import, its refused rows and export templates are stored in, file storage, sign-in, how the current person and account are found, the layout, the inline row limit, and choosing whether imports run within the request, through wallflower or through the app's own runner class are out of scope for this local and belong to `ixport-install`.