diff --git a/Gemfile b/Gemfile index 6dcd1ef7d4..3d476cf224 100644 --- a/Gemfile +++ b/Gemfile @@ -199,6 +199,8 @@ group :test do gem "webmock", "~> 3.23" # Interface capybara to chrome headless gem "cuprite" + # Read PDF files for tests + gem "pdf-reader" end # Windows does not include zoneinfo files, so bundle the tzinfo-data gem diff --git a/Gemfile.lock b/Gemfile.lock index b9908cfe7a..30c909b9c9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,7 @@ GEM remote: https://rubygems.org/ specs: + Ascii85 (1.1.1) actioncable (7.1.3.4) actionpack (= 7.1.3.4) activesupport (= 7.1.3.4) @@ -77,6 +78,7 @@ GEM tzinfo (~> 2.0) addressable (2.8.6) public_suffix (>= 2.0.2, < 6.0) + afm (0.2.2) annotate (3.2.0) activerecord (>= 3.2, < 8.0) rake (>= 10.4, < 14.0) @@ -288,6 +290,7 @@ GEM guard-compat (~> 1.1) rspec (>= 2.99.0, < 4.0) hashdiff (1.1.0) + hashery (2.1.2) hashie (5.0.0) httparty (0.22.0) csv @@ -441,6 +444,12 @@ GEM ast (~> 2.4.1) racc pdf-core (0.9.0) + pdf-reader (2.12.0) + Ascii85 (~> 1.0) + afm (~> 0.2.1) + hashery (~> 2.0) + ruby-rc4 + ttfunk pg (1.5.6) popper_js (2.11.8) prawn (2.4.0) @@ -591,6 +600,7 @@ GEM ruby-graphviz (1.2.5) rexml ruby-progressbar (1.13.0) + ruby-rc4 (0.1.5) ruby-vips (2.1.4) ffi (~> 1.12) ruby2_keywords (0.0.5) @@ -756,6 +766,7 @@ DEPENDENCIES omniauth-rails_csrf_protection orderly (~> 0.1) paper_trail + pdf-reader pg (~> 1.5.6) prawn-rails pry-doc @@ -787,4 +798,4 @@ DEPENDENCIES webmock (~> 3.23) BUNDLED WITH - 2.5.11 + 2.5.14 diff --git a/app/controllers/donations_controller.rb b/app/controllers/donations_controller.rb index 0771597fac..041abf9fa0 100644 --- a/app/controllers/donations_controller.rb +++ b/app/controllers/donations_controller.rb @@ -2,6 +2,19 @@ class DonationsController < ApplicationController before_action :authorize_admin, only: [:destroy] + def print + @donation = Donation.find(params[:id]) + respond_to do |format| + format.any do + pdf = DonationPdf.new(current_organization, @donation) + send_data pdf.compute_and_render, + filename: format("%s %s.pdf", @donation.source, sortable_date(@donation.created_at)), + type: "application/pdf", + disposition: "inline" + end + end + end + def index setup_date_range_picker diff --git a/app/pdfs/donation_pdf.rb b/app/pdfs/donation_pdf.rb new file mode 100644 index 0000000000..773e1b9095 --- /dev/null +++ b/app/pdfs/donation_pdf.rb @@ -0,0 +1,195 @@ +# Configures a Prawn PDF template for generating Donation receipts +class DonationPdf + include Prawn::View + include ItemsHelper + + class DonorInfo + attr_reader :name, :address, :email + + def initialize(donation) + if donation.nil? + raise "Must pass a Donation object" + end + case donation.source + when Donation::SOURCES[:donation_site] + @name = donation.donation_site.name + @address = donation.donation_site.address + @email = donation.donation_site.email + when Donation::SOURCES[:manufacturer] + @name = donation.manufacturer.name + @address = nil + @email = nil + when Donation::SOURCES[:product_drive] + @name = donation.product_drive_participant.business_name + @address = donation.product_drive_participant.address + @email = donation.product_drive_participant.email + when Donation::SOURCES[:misc] + @name = "Misc. Donation" + @address = nil + @email = nil + end + end + end + + def initialize(organization, donation) + @donation = Donation.includes(line_items: [:item]).find_by(id: donation.id) + @organization = organization + @donor = DonorInfo.new(@donation) + end + + def compute_and_render + font_families["OpenSans"] = PrawnRails.config["font_families"][:OpenSans] + font "OpenSans" + font_size 10 + + logo_image = if @organization.logo.attached? + StringIO.open(@organization.logo.download) + else + Organization::DIAPER_APP_LOGO + end + + footer_height = 35 + + # Bounding box containing non-footer elements + bounding_box [bounds.left, bounds.top], width: bounds.width, height: bounds.height - footer_height do + image logo_image, fit: [250, 85] + + bounding_box [bounds.right - 225, bounds.top], width: 225, height: 85 do + text @organization.name, align: :right + text @organization.address, align: :right + text @organization.email, align: :right + end + + font_size 12 + text "Issued on:", style: :bold + text @donation.issued_at.to_fs(:distribution_date) + move_up 24 + + font_size 12 + text "Donation from:", style: :bold, align: :right + font_size 10 + text @donor.name, align: :right + text @donor.address, align: :right + text @donor.email, align: :right + move_down 10 + # Get some additional vertical distance in left column if all donor info is nil + if @donor.name.nil? && @donor.address.nil? && @donor.email.nil? + move_down 10 + end + + font_size 12 + money_raised = "$0.00" + if @donation.money_raised && @donation.money_raised > 0 + money_raised = dollar_value(@donation.money_raised) + end + text "Money Raised In Dollars: #{money_raised}", inline_format: true + + move_down 10 + font_size 12 + text "Comments:", style: :bold + text @donation.comment + + move_down 20 + + data = donation_data + + hide_columns(data) + hidden_columns_length = column_names_to_hide.length + + font_size 11 + + # Line item table + table(data) do + self.header = true + self.cell_style = { + padding: [5, 20, 5, 20] + } + self.row_colors = %w[dddddd ffffff] + + cells.borders = [] + + # Header row + row(0).borders = [:bottom] + row(0).border_width = 2 + row(0).font_style = :bold + row(0).size = 9 + row(0).column(1..-1).borders = %i[bottom left] + + # Total Items footer row + row(-1).borders = [:top] + row(-1).font_style = :bold + row(-1).column(1..-1).borders = %i[top left] + row(-1).column(1..-1).border_left_color = "aaaaaa" + + # Footer spacing row + row(-2).borders = [:top] + row(-2).padding = [2, 0, 2, 0] + + column(0).width = 190 + (hidden_columns_length * 60) + + # Quantity column + column(1..-1).row(1..-3).borders = [:left] + column(1..-1).row(1..-3).border_left_color = "aaaaaa" + column(1).style align: :right + end + end + + number_pages "Page of ", + start_count_at: 1, + at: [bounds.right - 130, 22], + align: :right + + repeat :all do + # Page footer + bounding_box [bounds.left, bounds.bottom + footer_height], width: bounds.width do + stroke_bounds + font "OpenSans" + font_size 9 + stroke_horizontal_rule + move_down(5) + + logo_offset = (bounds.width - 190) / 2 + bounding_box([logo_offset, 0], width: 190, height: 33) do + text "Lovingly created with", valign: :center + image Organization::DIAPER_APP_LOGO, width: 75, vposition: :center, position: :right + end + end + end + + render + end + + def donation_data + data = [["Items Received", + "Value/item", + "In-Kind Value", + "Quantity"]] + data += @donation.line_items.sorted.map do |c| + [c.item.name, + dollar_value(c.item.value_in_cents), + dollar_value(c.value_per_line_item), + c.quantity] + end + data + [["", "", "", ""], + ["Total Items Received", + "", + dollar_value(@donation.value_per_itemizable), + @donation.line_items.total]] + end + + def hide_columns(data) + column_names_to_hide.each do |col_name| + col_index = data.first.find_index(col_name) + data.each { |line| line.delete_at(col_index) } if col_index.present? + end + end + + private + + def column_names_to_hide + in_kind_column_name = "In-Kind Value" + columns_to_hide = [] + columns_to_hide.push("Value/item", in_kind_column_name) if @organization.hide_value_columns_on_receipt + columns_to_hide + end +end diff --git a/app/views/donations/_donation_row.html.erb b/app/views/donations/_donation_row.html.erb index 50ea5a8618..9ce06cc75f 100644 --- a/app/views/donations/_donation_row.html.erb +++ b/app/views/donations/_donation_row.html.erb @@ -9,6 +9,7 @@ <%= truncate donation_row.comment, length: 140, separator: /\w+/ %> <%= view_button_to donation_path(donation_row) %> + <%= print_button_to print_donation_path(donation_row, format: :pdf) %> diff --git a/app/views/donations/show.html.erb b/app/views/donations/show.html.erb index 8e45413ae2..145d251a41 100644 --- a/app/views/donations/show.html.erb +++ b/app/views/donations/show.html.erb @@ -87,6 +87,7 @@ If you need to delete this donation or make a correction, please make the following items active: <%= @donation.inactive_items.map(&:name).join(", ") %> <% end %> + <%= print_button_to print_donation_path(@donation, format: :pdf), { size: "md" } %> diff --git a/config/routes.rb b/config/routes.rb index b44654f925..261370cd2e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -50,6 +50,9 @@ def set_up_flipper resources :distributions, only: [:index] do get :print, on: :member end + resources :donations, only: [:index] do + get :print, on: :member + end end # This is where a superadmin CRUDs all the things @@ -221,6 +224,7 @@ def set_up_flipper resources :product_drives resources :donations do + get :print, on: :member patch :add_item, on: :member patch :remove_item, on: :member end diff --git a/spec/pdfs/donation_pdf_spec.rb b/spec/pdfs/donation_pdf_spec.rb new file mode 100644 index 0000000000..21a8ee2174 --- /dev/null +++ b/spec/pdfs/donation_pdf_spec.rb @@ -0,0 +1,68 @@ +describe DonationPdf do + let(:donation_site) { create(:donation_site, name: "Site X", address: "1500 Remount Road, Front Royal, VA 22630", email: "test@example.com") } + let(:organization) { create(:organization) } + let(:donation) do + create(:donation, organization: organization, donation_site: donation_site, source: Donation::SOURCES[:donation_site], + comment: "A donation comment") + end + let(:item1) { FactoryBot.create(:item, name: "Item 1", package_size: 50, value_in_cents: 100) } + let(:item2) { FactoryBot.create(:item, name: "Item 2", value_in_cents: 200) } + let(:item3) { FactoryBot.create(:item, name: "Item 3", value_in_cents: 300) } + let(:item4) { FactoryBot.create(:item, name: "Item 4", package_size: 25, value_in_cents: 400) } + + let(:org_hiding_packages_and_values) do + FactoryBot.create(:organization, name: DEFAULT_TEST_ORGANIZATION_NAME, + hide_value_columns_on_receipt: true, hide_package_column_on_receipt: true) + end + let(:org_hiding_packages) { FactoryBot.create(:organization, name: DEFAULT_TEST_ORGANIZATION_NAME, hide_package_column_on_receipt: true) } + let(:org_hiding_values) { FactoryBot.create(:organization, name: DEFAULT_TEST_ORGANIZATION_NAME, hide_value_columns_on_receipt: true) } + + before(:each) do + create(:line_item, itemizable: donation, item: item1, quantity: 50) + create(:line_item, itemizable: donation, item: item2, quantity: 100) + end + + specify "#donation_data" do + results = described_class.new(organization, donation).donation_data + expect(results).to eq([ + ["Items Received", "Value/item", "In-Kind Value", "Quantity"], + ["Item 1", "$1.00", "$50.00", 50], + ["Item 2", "$2.00", "$200.00", 100], + ["", "", "", ""], + ["Total Items Received", "", "$250.00", 150] + ]) + end + + context "with donation data" do + it "hides value and package columns when true on organization" do + pdf = described_class.new(org_hiding_packages_and_values, donation) + data = pdf.donation_data + pdf.hide_columns(data) + expect(data).to eq([ + ["Items Received", "Quantity"], + ["Item 1", 50], + ["Item 2", 100], + ["", ""], + ["Total Items Received", 150] + ]) + end + end + + context "render pdf" do + it "renders correctly" do + pdf = described_class.new(organization, donation) + pdf_test = PDF::Reader.new(StringIO.new(pdf.compute_and_render)) + expect(pdf_test.page(1).text).to include(donation_site.name) + expect(pdf_test.page(1).text).to include(donation_site.address) + expect(pdf_test.page(1).text).to include(donation_site.email) + if donation.comment + expect(pdf_test.page(1).text).to include(donation.comment) + end + expect(pdf_test.page(1).text).to include("Money Raised In Dollars: $0.00") + expect(pdf_test.page(1).text).to include("Items Received") + expect(pdf_test.page(1).text).to match(/Item 1\s+\$1\.00\s+\$50\.00\s+50/) + expect(pdf_test.page(1).text).to match(/Item 2\s+\$2\.00\s+\$200\.00\s+100/) + expect(pdf_test.page(1).text).to include("Total Items Received") + end + end +end diff --git a/spec/requests/donations_requests_spec.rb b/spec/requests/donations_requests_spec.rb index 9f2e72e25d..325edcc1e6 100644 --- a/spec/requests/donations_requests_spec.rb +++ b/spec/requests/donations_requests_spec.rb @@ -28,6 +28,11 @@ expect(subject.body).to include("Source") expect(subject.body).to include("Details") end + it "shows a print button" do + page = Nokogiri::HTML(subject.body) + pdf = page.at_css("a[href*='print.pdf']") + expect(pdf.text).to include("Print") + end context "when given a product drive" do let(:product_drive) { create(:product_drive, name: "Drive Name") } @@ -83,10 +88,27 @@ end end + describe "GET #print" do + let(:item) { create(:item) } + let!(:donation) { create(:donation, :with_items, item: item) } + + it "returns http success" do + get print_donation_path(id: donation.id) + expect(response).to be_successful + end + end + describe "GET #show" do let(:item) { create(:item) } let!(:donation) { create(:donation, :with_items, item: item) } + it "shows a print button" do + get donation_path(id: donation.id) + page = Nokogiri::HTML(response.body) + pdf = page.at_css("a[href*='#{print_donation_path(id: donation.id)}']") + expect(pdf.text).to include("Print") + end + it "shows an enabled edit button" do get donation_path(id: donation.id) page = Nokogiri::HTML(response.body)