diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css index 3592ac1..d7f06a1 100644 --- a/app/assets/stylesheets/application.tailwind.css +++ b/app/assets/stylesheets/application.tailwind.css @@ -26,9 +26,23 @@ @apply h-20; } + input[type='file'] { + @apply py-2; + @apply text-base; + } + + input[type='file']::file-selector-button { + @apply border-0; + @apply m-0; + @apply p-0; + @apply w-0; + } + input[type='date'], + input[type='file'], input[type='password'], input[type='text'], + select, textarea { @apply bg-off-black; @apply block; @@ -41,13 +55,22 @@ @apply w-80; } + input[type='file']:invalid, + select:invalid { + @apply text-dark-gray; + } + input:focus[type='date'], + input:focus[type='file'], input:focus[type='password'], input:focus[type='text'], + select:focus, textarea:focus, input:hover[type='date'], + input:hover[type='file'], input:hover[type='password'], input:hover[type='text'], + select:hover, textarea:hover { @apply border-pink; @apply ring-0; diff --git a/app/controllers/admin/csv_uploads_controller.rb b/app/controllers/admin/csv_uploads_controller.rb new file mode 100644 index 0000000..1c06b6b --- /dev/null +++ b/app/controllers/admin/csv_uploads_controller.rb @@ -0,0 +1,23 @@ +class Admin::CsvUploadsController < ApplicationController + expose(:csv_upload) + + def create + if csv_upload.save + ParseCsvUploadJob.perform_later(csv_upload.id) + flash.notice = "CSV Upload successfully created" + redirect_to admin_csv_upload_path(csv_upload) + else + flash.alert = csv_upload.errors.full_messages.join(",") + redirect_to new_admin_csv_upload_path + end + end + + private + + def csv_upload_params + safe_params = params.require(:csv_upload).permit(:parser_class_name) + safe_params[:original_filename] = params[:file].original_filename + safe_params[:data] = params[:file].read + safe_params + end +end diff --git a/app/controllers/admin/model_counts_controller.rb b/app/controllers/admin/model_counts_controller.rb deleted file mode 100644 index 354bf0d..0000000 --- a/app/controllers/admin/model_counts_controller.rb +++ /dev/null @@ -1,3 +0,0 @@ -class Admin::ModelCountsController < ApplicationController - expose(:model_counts) { ModelCounts.calculate } -end diff --git a/app/controllers/model_counts_controller.rb b/app/controllers/model_counts_controller.rb new file mode 100644 index 0000000..077d3b4 --- /dev/null +++ b/app/controllers/model_counts_controller.rb @@ -0,0 +1,3 @@ +class ModelCountsController < ApplicationController + expose(:model_counts) { ModelCounts.calculate } +end diff --git a/app/jobs/parse_csv_upload_job.rb b/app/jobs/parse_csv_upload_job.rb new file mode 100644 index 0000000..f1cca3e --- /dev/null +++ b/app/jobs/parse_csv_upload_job.rb @@ -0,0 +1,9 @@ +class ParseCsvUploadJob < ApplicationJob + def perform(csv_upload_id) + csv_upload = CsvUpload.find_by(id: csv_upload_id) + return unless csv_upload + + parser = csv_upload.parser_class_name.constantize + parser.parse(csv_upload) + end +end diff --git a/app/models/csv_upload.rb b/app/models/csv_upload.rb new file mode 100644 index 0000000..149ee4e --- /dev/null +++ b/app/models/csv_upload.rb @@ -0,0 +1,11 @@ +require "csv" + +class CsvUpload < ApplicationRecord + validates_presence_of :data, :original_filename, :parser_class_name + + def parsed_data + CSV.parse(data) + rescue CSV::MalformedCSVError + nil + end +end diff --git a/app/parsers/wells_fargo_parser.rb b/app/parsers/wells_fargo_parser.rb new file mode 100644 index 0000000..30f084f --- /dev/null +++ b/app/parsers/wells_fargo_parser.rb @@ -0,0 +1,21 @@ +class WellsFargoParser + def self.parse(csv_upload) + table = csv_upload.parsed_data + return unless table + + wf_checking = FinancialAccount.wf_checking + + table.each do |row| + amount_cents = (row[1].to_r * 100).to_i + posted_on = Date.strptime(row[0], "%m/%d/%Y") + + attrs = { + amount_cents: amount_cents, + description: row[4], + posted_on: posted_on + } + + wf_checking.financial_transactions.create!(attrs) + end + end +end diff --git a/app/views/admin/csv_uploads/new.html.haml b/app/views/admin/csv_uploads/new.html.haml new file mode 100644 index 0000000..fc11f18 --- /dev/null +++ b/app/views/admin/csv_uploads/new.html.haml @@ -0,0 +1,8 @@ +%h1 New CSV Upload + += form_with model: [:admin, csv_upload], multipart: true do |form| + %select#csv_upload_parser_class_name(name="csv_upload[parser_class_name]" required="true") + %option(value="" disabled="true" selected hidden) pick parser + %option(value="WellsFargoParser") WellsFargoParser + %input#file_picker(required="true" type="file" name="file") + = form.button "create" diff --git a/app/views/admin/csv_uploads/show.html.haml b/app/views/admin/csv_uploads/show.html.haml new file mode 100644 index 0000000..0c4d096 --- /dev/null +++ b/app/views/admin/csv_uploads/show.html.haml @@ -0,0 +1 @@ +%h1 CSV Upload #{csv_upload.id} diff --git a/app/views/dashboard/show.html.haml b/app/views/dashboard/show.html.haml index b5f39b4..a05adae 100644 --- a/app/views/dashboard/show.html.haml +++ b/app/views/dashboard/show.html.haml @@ -1,17 +1,22 @@ %h1 Dashboard + +%h2 Public Pages %p= link_to "Artsy Viewer", artsy_viewer_path -%p= link_to "Cybertail", cybertail_path -%p= link_to "Financial Report", financial_report_path(year: Time.now.year) %p= link_to "Fairing Direball", faring_direball_path %p= link_to "Reading List", reading_list_path(year: Time.now.year) %p= link_to "Root", root_path %p= link_to "Style Pages", article_styles_path %p= link_to "Wishlist", wishlist_path +%h2 Private Pages +%p= link_to "Cybertail", cybertail_path +%p= link_to "Financial Report", financial_report_path(year: Time.now.year) +%p= link_to "Model Counts", model_counts_path +%p= link_to "Today", today_path + %h2 Admin Pages %p= link_to "Books", new_admin_book_path %p= link_to "Gift Ideas", admin_gift_ideas_path -%p= link_to "Model Counts", admin_model_counts_path %p= link_to "Post Bin", admin_post_bin_requests_path %p= link_to "Project List", admin_projects_path diff --git a/app/views/admin/model_counts/index.html.haml b/app/views/model_counts/index.html.haml similarity index 100% rename from app/views/admin/model_counts/index.html.haml rename to app/views/model_counts/index.html.haml diff --git a/app/views/static/form.html.haml b/app/views/static/form.html.haml index f9e63db..0b1759a 100644 --- a/app/views/static/form.html.haml +++ b/app/views/static/form.html.haml @@ -9,6 +9,12 @@ %input(type="date") %textarea(placeholder="write a note") %input(type="password" placeholder="password") + %input(required="true" type="file") + %select(required="true") + %option(value="" disabled="true" selected hidden) pick parser + %option first + %option second + %option third %button(type="submit") submit %h2 Another Form diff --git a/config/routes.rb b/config/routes.rb index b04c004..47800ac 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -4,33 +4,29 @@ get "dashboard", to: "dashboard#show" get "faring_direball", to: "faring_direball#index" get "financial_reports/:year", to: "financial_reports#show", as: :financial_report + get "model_counts", to: "model_counts#index", as: :model_counts get "reading-list/:year", to: "reading_list#index", as: :reading_list - get "today", to: "today#show" + get "today", to: "today#show", as: :today get "wishlist", to: "wishlist#index" get "work_weeks/:target", to: "work_weeks#show", as: :work_week - resources :gift_ideas, only: %i[update] + scope :style do + get :article, to: "static#article", as: "article_styles" + get :color, to: "static#color", as: "color_styles" + get :flashes, to: "static#flashes", as: "flashes_styles" + get :form, to: "static#form", as: "form_styles" + get :table, to: "static#table", as: "table_styles" + end get "sign_in", to: "password#new", as: :sign_in post "sign_in", to: "password#create" get "sign_out", to: "password#clear", as: :sign_out - namespace :api do - namespace :v1 do - get :ping, to: "ping#show" - post :post_bin, to: "post_bin#create" - post :raw_hooks, to: "raw_hooks#create" - - namespace :word_rot do - get :killswitch, to: "killswitch#show" - end - end - end + resources :gift_ideas, only: %i[update] namespace :admin do - get "model_counts", to: "model_counts#index", as: :model_counts - resources :books, only: %i[create edit new update] + resources :csv_uploads, only: %i[create new show] resources :gift_ideas resources :hooks, only: %i[create edit index] resources :post_bin_requests, only: %i[index show] @@ -38,12 +34,16 @@ resources :raw_hooks, only: %i[show] end - scope :style do - get :article, to: "static#article", as: "article_styles" - get :color, to: "static#color", as: "color_styles" - get :flashes, to: "static#flashes", as: "flashes_styles" - get :form, to: "static#form", as: "form_styles" - get :table, to: "static#table", as: "table_styles" + namespace :api do + namespace :v1 do + get :ping, to: "ping#show" + post :post_bin, to: "post_bin#create" + post :raw_hooks, to: "raw_hooks#create" + + namespace :word_rot do + get :killswitch, to: "killswitch#show" + end + end end root to: "static#root" diff --git a/db/migrate/20240213142420_create_csv_uploads.rb b/db/migrate/20240213142420_create_csv_uploads.rb new file mode 100644 index 0000000..a9ba600 --- /dev/null +++ b/db/migrate/20240213142420_create_csv_uploads.rb @@ -0,0 +1,10 @@ +class CreateCsvUploads < ActiveRecord::Migration[7.1] + def change + create_table :csv_uploads do |t| + t.text :data + t.string :parser_class_name + t.string :original_filename + t.timestamps + end + end +end diff --git a/spec/csv_files/empty.csv b/spec/csv_files/empty.csv new file mode 100644 index 0000000..e69de29 diff --git a/spec/csv_files/one_wf_transaction.csv b/spec/csv_files/one_wf_transaction.csv new file mode 100644 index 0000000..3f790a3 --- /dev/null +++ b/spec/csv_files/one_wf_transaction.csv @@ -0,0 +1 @@ +"12/29/2023","0.89","","","random fee" diff --git a/spec/factories/csv_upload.rb b/spec/factories/csv_upload.rb new file mode 100644 index 0000000..bc01dfd --- /dev/null +++ b/spec/factories/csv_upload.rb @@ -0,0 +1,7 @@ +FactoryBot.define do + factory :csv_upload do + data { "123,foo,true" } + original_filename { "sample-data.csv" } + parser_class_name { "SampleParser" } + end +end diff --git a/spec/jobs/parse_csv_upload_job_spec.rb b/spec/jobs/parse_csv_upload_job_spec.rb new file mode 100644 index 0000000..8450a98 --- /dev/null +++ b/spec/jobs/parse_csv_upload_job_spec.rb @@ -0,0 +1,41 @@ +require "rails_helper" + +describe ParseCsvUploadJob do + let(:parser_class_name) { "WellsFargoParser" } + + let(:csv_upload) do + FactoryBot.create(:csv_upload, parser_class_name: parser_class_name) + end + + let(:csv_upload_id) { csv_upload.id } + + context "with an invalid csv_upload_id" do + let(:csv_upload_id) { "invalid" } + + it "exits early" do + job = ParseCsvUploadJob.new + expect do + job.perform(csv_upload_id) + end.to_not raise_error + end + end + + context "with a CsvUpload that has an invalid parser_class_name" do + let(:parser_class_name) { "InvalidParser" } + + it "raises an error" do + job = ParseCsvUploadJob.new + expect do + job.perform(csv_upload_id) + end.to raise_error(NameError) + end + end + + context "with a valid CsvUpload" do + it "calls the parser with that CsvUpload" do + job = ParseCsvUploadJob.new + expect(WellsFargoParser).to receive(:parse).with(csv_upload) + job.perform(csv_upload_id) + end + end +end diff --git a/spec/models/csv_upload_spec.rb b/spec/models/csv_upload_spec.rb new file mode 100644 index 0000000..a54974f --- /dev/null +++ b/spec/models/csv_upload_spec.rb @@ -0,0 +1,44 @@ +require "rails_helper" + +describe CsvUpload do + describe "validation" do + context "without required attrs" do + it "is invalid" do + csv_upload = CsvUpload.new + expect(csv_upload).to_not be_valid + end + end + + context "with required attrs" do + it "is valid" do + csv_upload = CsvUpload.new( + data: "foo,bar,baz", + original_filename: "dummy-data.csv", + parser_class_name: "DummyParser" + ) + + expect(csv_upload).to be_valid + end + end + end + + describe "#parsed_data" do + let(:csv_upload) { FactoryBot.create(:csv_upload, data: data) } + + context "with data that fails to parse" do + let(:data) { '"invalid' } + + it "returns nil" do + expect(csv_upload.parsed_data).to eq nil + end + end + + context "with data that parses" do + let(:data) { "abc,123,true" } + + it "returns that parsed data" do + expect(csv_upload.parsed_data).to eq [["abc", "123", "true"]] + end + end + end +end diff --git a/spec/parsers/wells_fargo_parser_spec.rb b/spec/parsers/wells_fargo_parser_spec.rb new file mode 100644 index 0000000..03f0137 --- /dev/null +++ b/spec/parsers/wells_fargo_parser_spec.rb @@ -0,0 +1,62 @@ +require "rails_helper" + +describe WellsFargoParser do + describe ".parse" do + let!(:wf_checking) { FactoryBot.create(:wf_checking) } + let(:csv_upload) { FactoryBot.create(:csv_upload, data: data) } + + context "with nil parsed data" do + let(:data) { '"invalid' } + + it "exits early" do + expect do + WellsFargoParser.parse(csv_upload) + end.to_not raise_error + end + end + + context "with a properly formatted transaction" do + let(:data) { "12/29/2023,-0.89,,,random fee" } + + it "creates a transaction for the Wells Fargo Checking account" do + WellsFargoParser.parse(csv_upload) + expect(wf_checking.financial_transactions.count).to eq 1 + + financial_transaction = wf_checking.financial_transactions.last + expect(financial_transaction.amount_cents).to eq(-89) + expect(financial_transaction.description).to eq "random fee" + expect(financial_transaction.posted_on).to eq Date.parse("2023-12-29") + end + end + + context "with a few transactions" do + let(:data) do + <<~EOL + 12/01/2023,100.00,ignore,,check deposit + 12/07/2023,-77.77,,ignore,groceries + 12/29/2023,-0.89,,,random fee + EOL + end + + it "creates those transactions on the Wells Fargo Checking account" do + WellsFargoParser.parse(csv_upload) + + ordered_transactions = wf_checking.financial_transactions.order(:posted_on) + expect(ordered_transactions.count).to eq 3 + + amounts = ordered_transactions.pluck(:amount_cents) + expect(amounts).to eq [100_00, -77_77, -89] + + dates = ordered_transactions.pluck(:posted_on) + expect(dates.map(&:to_s)).to eq ["2023-12-01", "2023-12-07", "2023-12-29"] + + descriptions = ordered_transactions.pluck(:description) + expect(descriptions).to eq [ + "check deposit", + "groceries", + "random fee" + ] + end + end + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 3c95417..5aa869f 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -16,6 +16,10 @@ config.infer_spec_type_from_file_location! config.use_transactional_fixtures = true + config.before do + ActiveJob::Base.queue_adapter = :test + end + config.before(:each, type: :system) do Selenium::WebDriver.logger.ignore(:deprecations) Capybara.server = :puma, {Silent: true} diff --git a/spec/system/csv_uploads/admin_creates_csv_upload_spec.rb b/spec/system/csv_uploads/admin_creates_csv_upload_spec.rb new file mode 100644 index 0000000..b8bf8e5 --- /dev/null +++ b/spec/system/csv_uploads/admin_creates_csv_upload_spec.rb @@ -0,0 +1,44 @@ +require "rails_helper" + +describe "Admin creates CsvUpload" do + include_context "admin password matches" + + scenario "without a parser" do + visit "/admin/csv_uploads/new" + click_on "create" + expect(CsvUpload.count).to eq 0 + select = page.find("#csv_upload_parser_class_name") + error_message = select.native.attribute("validationMessage") + expect(error_message).to eq "Please select an item in the list." + end + + scenario "without a file" do + visit "/admin/csv_uploads/new" + select "WellsFargoParser", from: "csv_upload_parser_class_name" + click_on "create" + expect(CsvUpload.count).to eq 0 + file_input = page.find("#file_picker") + error_message = file_input.native.attribute("validationMessage") + expect(error_message).to eq "Please select a file." + end + + scenario "with an empty file" do + visit "/admin/csv_uploads/new" + select "WellsFargoParser", from: "csv_upload_parser_class_name" + attach_file "file", "spec/csv_files/empty.csv" + click_on "create" + expect(CsvUpload.count).to eq 0 + expect(page).to have_content "Data can't be blank" + end + + scenario "with valid financial transactions" do + visit "/admin/csv_uploads/new" + select "WellsFargoParser", from: "csv_upload_parser_class_name" + attach_file "file", "spec/csv_files/one_wf_transaction.csv" + click_on "create" + expect(page).to have_content "CSV Upload successfully created" + csv_upload = CsvUpload.last + expect(page).to have_content "CSV Upload #{csv_upload.id}" + expect(ParseCsvUploadJob).to have_been_enqueued + end +end diff --git a/spec/system/model_counts/admin_views_model_counts_spec.rb b/spec/system/model_counts/admin_views_model_counts_spec.rb index d07cb87..7df9bdc 100644 --- a/spec/system/model_counts/admin_views_model_counts_spec.rb +++ b/spec/system/model_counts/admin_views_model_counts_spec.rb @@ -4,13 +4,14 @@ include_context "admin password matches" scenario "with no models" do - visit "/admin/model_counts" + visit "/model_counts" actual_rows = page.all("tbody tr").map(&:text) expected_rows = [ "Artwork 0", "Book 0", + "CsvUpload 0", "FinancialAccount 0", "FinancialStatement 0", "FinancialTransaction 0", @@ -31,6 +32,7 @@ scenario "with some models" do FactoryBot.create(:book) + FactoryBot.create(:csv_upload) FactoryBot.create(:gift_idea) FactoryBot.create(:killswitch) FactoryBot.create(:post_bin_request) @@ -54,13 +56,14 @@ lineup: lineup ) - visit "/admin/model_counts" + visit "/model_counts" actual_rows = page.all("tbody tr").map(&:text) expected_rows = [ "Artwork 1", "Book 1", + "CsvUpload 1", "FinancialAccount 1", "FinancialStatement 1", "FinancialTransaction 1", @@ -78,6 +81,6 @@ expect(actual_rows).to match_array(expected_rows) - expect(page.find("tfoot tr").text).to eq "Total 15" + expect(page.find("tfoot tr").text).to eq "Total 16" end end