Skip to content

Commit

Permalink
Upload transactions in bulk as CSV
Browse files Browse the repository at this point in the history
This exposes the functionality of ImportTransactions via a file upload
that accepts CSV. The CSV is parsed in the controller and the resulting
rows fed into the importer, which itself is thoroughly unit-tested.

If any errors occur, then no transactions are imported, and the errors
are displayed to the user. If all the rows are valid, then the
transactions are saved and a success message is displayed.
  • Loading branch information
James Coglan committed Sep 17, 2020
1 parent 50c1d47 commit ca47d7c
Show file tree
Hide file tree
Showing 9 changed files with 142 additions and 2 deletions.
28 changes: 28 additions & 0 deletions app/controllers/staff/transaction_uploads_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,25 @@ def show
response.stream.close
end

def update
@report_presenter = ReportPresenter.new(@report)
rows = parse_transactions_from_upload

if rows.nil?
@errors = []
flash.now[:error] = t("action.transaction.upload.file_missing")
return
end

importer = ImportTransactions.new(report: @report, uploader: current_user)
importer.import(rows)
@errors = importer.errors

if @errors.empty?
flash.now[:notice] = t("action.transaction.upload.success")
end
end

private def authorize_report
@report = Report.find(params[:report_id])
authorize @report, :show?
Expand All @@ -38,4 +57,13 @@ def show
activity.roda_identifier_compound,
]
end

private def parse_transactions_from_upload
file = params[:report]&.fetch(:transaction_csv, nil)
return nil unless file

CSV.parse(file.read, headers: true)
rescue
nil
end
end
6 changes: 5 additions & 1 deletion app/services/import_transactions.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
require "date"

class ImportTransactions
Error = Struct.new(:row, :column, :value, :message)
Error = Struct.new(:row, :column, :value, :message) {
def csv_row
row + 2
end
}

attr_reader :errors

Expand Down
15 changes: 15 additions & 0 deletions app/views/staff/transaction_uploads/_error_table.html.haml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
%table.govuk-table
%caption.govuk-table__caption List of errors in your uploaded CSV file
%thead.govuk-table__head
%tr.govuk-table__row
%th.govuk-table__header{scope: "col"} Column
%th.govuk-table__header{scope: "col"} Row
%th.govuk-table__header{scope: "col"} Current Value
%th.govuk-table__header{scope: "col"} Error description
%tbody.govuk-table__body
- @errors.each do |error|
%tr.govuk-table__row
%td.govuk-table__cell= error.column
%td.govuk-table__cell= error.csv_row
%td{class: "govuk-table__cell govuk-!-font-weight-bold error-text"}= error.value
%td.govuk-table__cell= error.message
9 changes: 9 additions & 0 deletions app/views/staff/transaction_uploads/_upload_form.html.haml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.govuk-grid-row
.govuk-grid-column-two-thirds
= form_for @report_presenter, url: report_transaction_upload_path(@report_presenter) do |f|

= f.govuk_file_field :transaction_csv,
label: { text: t("form.label.transaction.csv_file") },
hint_text: t("form.hint.transaction.csv_file")

= f.govuk_submit t("action.transaction.upload.button")
2 changes: 2 additions & 0 deletions app/views/staff/transaction_uploads/new.html.haml
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@
.govuk-grid-row
.govuk-grid-column-full.page-actions
= link_to t("action.transaction.download.button"), report_transaction_upload_path(@report_presenter, format: :csv), class: "govuk-button govuk-button--secondary govuk-!-margin-left-4"

= render partial: "upload_form"
12 changes: 12 additions & 0 deletions app/views/staff/transaction_uploads/update.html.haml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
%main.govuk-main-wrapper#main-content{ role: "main" }
.govuk-grid-row
.govuk-grid-column-two-thirds
%h1.govuk-heading-xl
= t("page_title.transaction.upload")

- unless @errors.empty?
.govuk-grid-row
.govuk-grid-column-full
= render partial: "error_table"

= render partial: "upload_form"
5 changes: 5 additions & 0 deletions config/locales/models/transaction.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,14 @@ en:
download:
button: Download CSV template
upload:
button: Upload and continue
file_missing: Please upload a valid CSV file
link: Upload actuals
success: The transactions were successfully imported.
form:
label:
transaction:
csv_file: Transaction spreadsheet
currency: Currency
description: Describe the transaction
disbursement_channel: Disbursement channel (optional)
Expand All @@ -31,6 +35,7 @@ en:
receiving_organisation: Receiving organisation
hint:
transaction:
csv_file: Upload a spreadsheet containing transaction information in CSV format.
date: If you're reporting quarterly data, select the last day of the quarter. For example, 31 12 2020
description: For example, 2020 quarter one spend on the Early Career Research Network project.
disbursement_channel: The channel through which the funds will flow for this transaction.
Expand Down
2 changes: 1 addition & 1 deletion config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@

resources :reports, only: [:show, :edit, :update, :index] do
resource :state, only: [:edit, :update], controller: :reports_state
resource :transaction_upload, only: [:new, :show]
resource :transaction_upload, only: [:new, :show, :update]
get "variance" => "report_variance#show"
get "budgets" => "report_budgets#show"
end
Expand Down
65 changes: 65 additions & 0 deletions spec/features/staff/users_can_upload_transactions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,69 @@
},
])
end

scenario "not uploading a file" do
click_button t("action.transaction.upload.button")
expect(Transaction.count).to eq(0)
expect(page).to have_text(t("action.transaction.upload.file_missing"))
end

scenario "uploading a valid set of transactions" do
ids = [project, sibling_project].map(&:roda_identifier_compound)

upload_csv <<~CSV
Activity RODA Identifier | Date | Value | Receiving Organisation Name | Receiving Organisation Type | Receiving Organisation IATI Reference | Disbursement Channel | Description
#{ids[0]} | 2020-04-01 | 20 | Example University | 80 | | 4 |
#{ids[1]} | 2020-04-02 | 30 | Example Foundation | 60 | | 4 |
CSV

expect(Transaction.count).to eq(2)
expect(page).to have_text(t("action.transaction.upload.success"))
expect(page).not_to have_xpath("//tbody/tr")
end

scenario "uploading an invalid set of transactions" do
ids = [project, sibling_project].map(&:roda_identifier_compound)

upload_csv <<~CSV
Activity RODA Identifier | Date | Value | Receiving Organisation Name | Receiving Organisation Type | Receiving Organisation IATI Reference | Disbursement Channel | Description
#{ids[0]} | 2020-04-01 | 0 | Example University | 80 | | 4 |
#{ids[1]} | 2020-04-02 | 30 | Example Foundation | 61 | | 5 |
CSV

expect(Transaction.count).to eq(0)
expect(page).not_to have_text(t("action.transaction.upload.success"))

within "//tbody/tr[1]" do
expect(page).to have_xpath("td[1]", text: "Value")
expect(page).to have_xpath("td[2]", text: "2")
expect(page).to have_xpath("td[3]", text: "0")
expect(page).to have_xpath("td[4]", text: t("activerecord.errors.models.transaction.attributes.value.other_than"))
end

within "//tbody/tr[2]" do
expect(page).to have_xpath("td[1]", text: "Receiving Organisation Type")
expect(page).to have_xpath("td[2]", text: "3")
expect(page).to have_xpath("td[3]", text: "61")
expect(page).to have_xpath("td[4]", text: t("importer.errors.transaction.invalid_iati_organisation_type"))
end

within "//tbody/tr[3]" do
expect(page).to have_xpath("td[1]", text: "Disbursement Channel")
expect(page).to have_xpath("td[2]", text: "3")
expect(page).to have_xpath("td[3]", text: "5")
expect(page).to have_xpath("td[4]", text: t("importer.errors.transaction.invalid_iati_disbursement_channel"))
end
end

def upload_csv(content)
file = Tempfile.new("transactions.csv")
file.write(content.gsub(/ *\| */, ","))
file.close

attach_file "report[transaction_csv]", file.path
click_button t("action.transaction.upload.button")

file.unlink
end
end

0 comments on commit ca47d7c

Please sign in to comment.