Skip to content

Commit

Permalink
feat: use active storage for invoice attachments
Browse files Browse the repository at this point in the history
until now invoice attachments were stored in the database directly,
this led to performance issues see #1037.
This commit migrates the invoice attachments to use active storage.
Additionally multiple attachments are now supported, a small preview
is rendered and the delete flow was adapted.
  • Loading branch information
yksflip committed Feb 10, 2024
1 parent f2be50d commit 65c0fda
Show file tree
Hide file tree
Showing 14 changed files with 77 additions and 43 deletions.
11 changes: 6 additions & 5 deletions app/controllers/finance/invoices_controller.rb
Expand Up @@ -79,11 +79,12 @@ def destroy
redirect_to finance_invoices_url
end

def attachment
@invoice = Invoice.find(params[:invoice_id])
type = MIME::Types[@invoice.attachment_mime].first
filename = "invoice_#{@invoice.id}_attachment.#{type.preferred_extension}"
send_data(@invoice.attachment_data, filename: filename, type: type)
def delete_attachment
attachment = ActiveStorage::Attachment.find(params[:attachment_id])
attachment.purge_later
respond_to do |format|
format.js
end
end

private
Expand Down
28 changes: 7 additions & 21 deletions app/models/invoice.rb
Expand Up @@ -7,10 +7,11 @@ class Invoice < ApplicationRecord
belongs_to :financial_link, optional: true
has_many :deliveries, dependent: :nullify
has_many :orders, dependent: :nullify
has_many_attached :attachments

validates :supplier_id, presence: true
validates :amount, :deposit, :deposit_credit, numericality: true
validate :valid_attachment
validate :valid_attachments

scope :unpaid, -> { where(paid_on: nil) }
scope :without_financial_link, -> { where(financial_link: nil) }
Expand All @@ -20,19 +21,6 @@ class Invoice < ApplicationRecord
# Replace numeric seperator with database format
localize_input_of :amount, :deposit, :deposit_credit

def attachment=(incoming_file)
self.attachment_data = incoming_file.read
# allow to soft-fail when FileMagic isn't present and removed from Gemfile (e.g. Heroku)
self.attachment_mime = defined?(FileMagic) ? FileMagic.new(FileMagic::MAGIC_MIME).buffer(attachment_data) : 'application/octet-stream'
end

def delete_attachment=(value)
return unless value == '1'

self.attachment_data = nil
self.attachment_mime = nil
end

def user_can_edit?(user)
user.role_finance? || (user.role_invoices? && !paid_on && created_by.try(:id) == user.id)
end
Expand Down Expand Up @@ -62,12 +50,10 @@ def expected_amount

protected

def valid_attachment
return unless attachment_data

mime = MIME::Type.simplified(attachment_mime)
return if ['application/pdf', 'image/jpeg'].include? mime

errors.add :attachment, I18n.t('model.invoice.invalid_mime', mime: mime)
# validates that the attachments are jpeg, png or pdf
def valid_attachments
attachments.each do |attachment|
errors.add(:attachments, I18n.t('model.invoice.invalid_mime', mime: attachment.content_type)) unless attachment.content_type.in?(%w[image/jpeg image/png application/pdf])
end
end
end
17 changes: 14 additions & 3 deletions app/views/finance/invoices/_form.html.haml
Expand Up @@ -23,9 +23,20 @@
= f.input :deposit, as: :string
= f.input :deposit_credit, as: :string
= render 'shared/custom_form_fields', f: f, type: :invoice
= f.input :attachment, as: :file, hint: t('.attachment_hint')
- if f.object.attachment_data.present?
= f.input :delete_attachment, as: :boolean
= f.input :attachments, as: :file, hint: t('.attachment_hint'), input_html: {multiple: true}, direct_upload: true
- if f.object.attachments.attached?
.control-group
%label.control-label
= t('ui.delete_attachment')
- f.object.attachments.reject(&:new_record?).each do |attachment|
.controls.control-text{id: "attachment_#{attachment.id}"}
= f.hidden_field :attachments, value: attachment.signed_id, multiple: true
= link_to url_for(attachment), target: "_blank" do
= image_tag attachment.preview(resize_to_limit: [100, 100]) if attachment.previewable?
= image_tag attachment.variant(resize_to_limit: [100, 100]) if attachment.variable?
= link_to 'Delete', delete_attachment_finance_invoice_path(f.object, attachment_id: attachment.id), method: :delete, remote: true, class: 'btn'
%label= attachment.filename
%hr
= f.input :note
.form-actions
= f.submit class: 'btn'
Expand Down
1 change: 1 addition & 0 deletions app/views/finance/invoices/delete_attachment.js.erb
@@ -0,0 +1 @@
$("#attachment_<%= params[:attachment_id] %>").remove();
10 changes: 7 additions & 3 deletions app/views/finance/invoices/show.html.haml
Expand Up @@ -63,10 +63,14 @@
%dt= heading_helper(Invoice, :total) + ':'
%dd= number_to_currency total

- if @invoice.attachment_data
- if @invoice.attachments.attached?
%dt= heading_helper(Invoice, :attachment) + ':'
%dd= link_to t('ui.download'), finance_invoice_attachment_path(@invoice)

- for attachment in @invoice.attachments
%dd
=link_to url_for(attachment), target: "_blank" do
= image_tag attachment.preview(resize_to_limit: [100, 100]), style: 'margin: 0px 10px 10px 0px;' if attachment.previewable?
= image_tag attachment.variant(resize_to_limit: [100, 100]), style: 'margin: 0px 10px 10px 0px;' if attachment.variable?
= attachment.filename
%dt= heading_helper(Invoice, :note) + ':'
%dd= simple_format(@invoice.note)

Expand Down
5 changes: 3 additions & 2 deletions app/views/finance/invoices/unpaid.html.haml
Expand Up @@ -19,8 +19,9 @@
%span{style: "color:#{invoice_amount_diff < 0 ? 'red' : 'green'}"}
= invoice_amount_diff > 0 ? '+' : '-'
= number_to_currency(invoice_amount_diff.abs)
- if invoice.attachment_data?
= link_to finance_invoice_attachment_path(invoice) do
- if invoice.attachments.attached?
- for attachment in invoice.attachments
= link_to attachment.filename, url_for(attachment)
= glyph :download
- if invoice.note?
= '(' + invoice.note + ')'
Expand Down
2 changes: 1 addition & 1 deletion config/locales/de.yml
Expand Up @@ -972,7 +972,7 @@ de:
edit:
title: Rechnung bearbeiten
form:
attachment_hint: Es sind nur JPEG und PDF erlaubt.
attachment_hint: Es sind nur JPEG, PNG und PDF erlaubt.
index:
action_new: Neue Rechnung anlegen
show_unpaid: Unbezahlte Rechnungen anzeigen
Expand Down
2 changes: 1 addition & 1 deletion config/locales/en.yml
Expand Up @@ -972,7 +972,7 @@ en:
edit:
title: Edit invoice
form:
attachment_hint: Only JPEG and PDF are allowed.
attachment_hint: Only JPEG, PNG and PDF are allowed.
index:
action_new: Create new invoice
show_unpaid: Show unpaid invoices
Expand Down
2 changes: 1 addition & 1 deletion config/locales/es.yml
Expand Up @@ -972,7 +972,7 @@ es:
edit:
title: Edita factura
form:
attachment_hint: Sólo se permiten los formatos JPEG y PDF.
attachment_hint: Sólo se permiten los formatos JPEG, PNG y PDF.
index:
action_new: Crea nueva factura
show_unpaid: Mostrar facturas no pagadas
Expand Down
2 changes: 1 addition & 1 deletion config/locales/nl.yml
Expand Up @@ -972,7 +972,7 @@ nl:
edit:
title: Factuur bewerken
form:
attachment_hint: Alleen JPEG en PDF zijn toegestaan.
attachment_hint: Alleen JPEG, PNG en PDF zijn toegestaan.
index:
action_new: Nieuwe factuur toevoegen
show_unpaid: Toon onbetaalde facturen
Expand Down
2 changes: 1 addition & 1 deletion config/locales/tr.yml
Expand Up @@ -971,7 +971,7 @@ tr:
edit:
title: Faturayı düzenle
form:
attachment_hint: Sadece JPEG ve PDF dosyaları kabul edilir.
attachment_hint: Sadece JPEG, PNG ve PDF dosyaları kabul edilir.
index:
action_new: Yeni fatura oluştur
show_unpaid: Ödenmemiş faturaları göster
Expand Down
2 changes: 1 addition & 1 deletion config/routes.rb
Expand Up @@ -170,9 +170,9 @@
end

resources :invoices do
get :attachment
get :form_on_supplier_id_change, on: :collection
get :unpaid, on: :collection
delete 'delete_attachment/:attachment_id', to: 'invoices#delete_attachment', as: 'delete_attachment', on: :member
end

resources :links, controller: 'financial_links', only: %i[create show] do
Expand Down
32 changes: 32 additions & 0 deletions db/migrate/20240126111615_move_attachment_to_active_storage.rb
@@ -0,0 +1,32 @@
class MoveAttachmentToActiveStorage < ActiveRecord::Migration[7.0]
def up
Invoice.find_each do |invoice|
if invoice.attachment_data.present? && invoice.attachment_mime.present?
blob = ActiveStorage::Blob.create_and_upload!(
io: StringIO.new(invoice.attachment_data),
filename: "attachment",

Check failure on line 7 in db/migrate/20240126111615_move_attachment_to_active_storage.rb

View workflow job for this annotation

GitHub Actions / test

Style/StringLiterals: Prefer single-quoted strings when you don't need string interpolation or special symbols.

Check failure on line 7 in db/migrate/20240126111615_move_attachment_to_active_storage.rb

View workflow job for this annotation

GitHub Actions / test

Style/StringLiterals: Prefer single-quoted strings when you don't need string interpolation or special symbols.
content_type: invoice.attachment_mime
)

invoice.attachments.attach(blob)
end
end
remove_column :invoices, :attachment_data
remove_column :invoices, :attachment_mime
end

def down
add_column :invoices, :attachment_data, :binary
add_column :invoices, :attachment_mime, :string

Invoice.find_each do |invoice|
if invoice.attachments.attached?
attachment = invoice.attachments.first
invoice.update(
attachment_data: attachment.download,
attachment_mime: attachment.blob.content_type
)
end
end
end
end

Check failure on line 32 in db/migrate/20240126111615_move_attachment_to_active_storage.rb

View workflow job for this annotation

GitHub Actions / test

Layout/TrailingEmptyLines: Final newline missing.

Check failure on line 32 in db/migrate/20240126111615_move_attachment_to_active_storage.rb

View workflow job for this annotation

GitHub Actions / test

Layout/TrailingEmptyLines: Final newline missing.
4 changes: 1 addition & 3 deletions db/schema.rb
Expand Up @@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.

ActiveRecord::Schema[7.0].define(version: 2023_02_15_085312) do
ActiveRecord::Schema[7.0].define(version: 2024_01_26_111615) do
create_table "action_text_rich_texts", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.string "name", null: false
t.text "body", size: :long
Expand Down Expand Up @@ -251,8 +251,6 @@
t.datetime "created_at", precision: nil
t.datetime "updated_at", precision: nil
t.integer "created_by_user_id"
t.string "attachment_mime"
t.binary "attachment_data", size: :medium
t.integer "financial_link_id"
t.index ["supplier_id"], name: "index_invoices_on_supplier_id"
end
Expand Down

0 comments on commit 65c0fda

Please sign in to comment.