Class: Upload

Inherits:
ApplicationRecord show all
Includes:
Models::Auditable
Defined in:
app/models/upload.rb

Overview

== Schema Information

Table name: uploads
Database name: primary

id :integer not null, primary key
asset :jsonb
attachment_mime_type :string
attachment_name :string
attachment_processing :boolean
attachment_size :integer
attachment_uid :string
category :string(255)
document_date :date
expiration_date :date
extracted_markdown :text
note :text
position :integer
resource_type :string(255)
source_layout :jsonb
template :string
thumbnail_asset :jsonb
type :string(30)
typst_source :text
created_at :timestamp
updated_at :timestamp
resource_id :integer

Indexes

idx_resource_type_category (resource_type,category) WHERE (resource_id IS NULL)
idx_resource_type_category_resource_id (resource_type,category,resource_id)
index_uploads_on_category (category)
index_uploads_on_resource_type_and_resource_id (resource_type,resource_id)
index_uploads_on_type (type) WHERE (type IS NOT NULL)

rubocop:disable Metrics/ClassLength -- pre-existing god-class (370+ lines on master); decompose separately

Direct Known Subclasses

Literature

Defined Under Namespace

Classes: CurrentPaths

Constant Summary collapse

MODEL_TO_CATEGORIES =

.each_with_object({}) {|cat, hsh| hsh[cat] = cat.to_s.humanize.titleize }.freeze

{
  activity: %i[activity_attachment msrp_quote_pdf_transmission organization_quote_pdf_transmission room_layout],
  call_record: %i[call_record],
  certification: %i[agreement liability_insurance],
  check: %i[check],
  credit_memo: %i[credit_memo_pdf statement_of_account_pdf],
  contact: %i[liability_insurance contract],
  customer: %i[liability_insurance contract],
  delivery: %i[all_intl_forms_pdf all_labels_pdf declaration_232_pdf declaration_232_xlsm manually_generated_combined_pdf manual_ship_label pick_slip_pdf serial_numbers_pdf ship_bol_pdf ship_ci_pdf electronic_ship_ci_pdf split_pick_slip_pdf],
  employee: %i[certificate contract resume form],
  item: %i[usmca_certificate other_certificate],
  invoice: %i[invoice_pdf statement_of_account_pdf],
  mail_activity: %i[all_labels_pdf],
  manifest: %i[manifest_pdf],
  # manifest_csv is write-dead — the FTP path that produced it is gone and
  # Spee-Dee manifests are never transmitted — but ~1.4k historical uploads
  # still carry the category, so it stays for them to render. Do not remove.
  speedee_manifest: %i[summary_pdf manifest_csv],
  opportunity: %i[installation_plan_cad installation_plan_image room_layout room_layout_image],
  order: %i[custom_order_agreement custom_packing_slip_pdf early_ship_label installation_plan_pdf invalid_pick_slip_pdf manual_ship_label manual_smart_preset_form master_carton_label other pick_slip_pdf purchase_order ship_bol_pdf],
  purchase_order: %i[purchase_order],
  quote: %i[copy_of_plans_pdf msrp_quote_pdf organization_quote_pdf],
  rma: %i[letter_ship_label_pdf photo rma_return_instructions_pdf],
  rma_item: %i[photo not_returned_photo],
  room_configuration: %i[design_guideline electrical_plan_pdf installation_plan_cad installation_plan_image installation_plan_pdf installation_heated_area other photo room_layout room_layout_image warranty_card warranty_card_pdf
                         heat_loss_customer_form heat_loss_report multizone_plan multizone_plan_pdf],
  service_job: %i[room_layout tstat_job_result],
  shipment: %i[container_label letter_ship_label_pdf manual_ship_label ship_bol_pdf ship_label_image ship_label_pdf],
  statement_of_account: %i[statement_of_account_pdf],
  store: %i[super_pick_slip],
  supplier: %i[price_list contract],
  edi_communication_log: %i[custom_packing_slip_pdf feed_document_result_xml feed_document_result_json feed_document_result_gzip],
  exported_catalog_item_packet: %i[exported_packet],
  catalog_item: %i[amalytix_snapshot],
  video: %i[audio_extraction captions],
  warranty: %i[warranty_card warranty_card_pdf photo],
  assistant_conversation: %i[assistant_attachment],
  tax_exemption: %i[tax_exemption]
}.freeze
UNCLASSIFIED_CATEGORIES =

Unclassified categories.

%i[accounting_documents all_postage_labels_path_pdf article_pdf fedex_manifest google_local_inventory_feed misc preview_organization_quote_pdf price_list_pdf reviews_io_product_feed super_invoice super_pick_slip super_pick_slip_pdf super_serial_number_label_pdf super_soa
agreement_pdf order_review archive declaration_of_steel_aluminimum_copper].freeze
EXCLUDE_CATEGORIES_FOR_MANUAL_UPLOAD =

we don't want people accidentally manually uploading the wrong category when these are only generated by HW

%i[all_postage_labels_path_pdf article_pdf preview_organization_quote_pdf price_list_pdf super_pick_slip_pdf super_serial_number_label_pdf super_soa agreement_pdf electrical_plan_pdf installation_plan_pdf multizone_plan_pdf].freeze
CATEGORIES =

Categories.

(MODEL_TO_CATEGORIES.values.flatten + UNCLASSIFIED_CATEGORIES).uniq
IMAGE_EXTENSIONS =

Image extensions.

Marcel::EXTENSIONS.select { |_, mime_type| mime_type.start_with?('image/') }.keys.freeze
RESOURCES_WITH_UPLOADS_COUNT =

Counter cache for uploads_count on polymorphic resources
Only increment for resource types that have an uploads_count column

%w[Activity Customer Contact Employee Order Quote Rma Shipment Supplier].freeze

Constants included from Models::Auditable

Models::Auditable::ALWAYS_IGNORED

Constants included from Models::Schedulable

Models::Schedulable::SIMPLE_FORM_OPTIONS

Instance Attribute Summary collapse

Belongs to collapse

Methods included from Models::Auditable

#creator, #updater

Has one collapse

Has many collapse

Has and belongs to many collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Models::Auditable

#all_skipped_columns, #audit_reference_data, #should_not_save_version, #stamp_record

Methods inherited from ApplicationRecord

ransackable_associations, ransackable_attributes, ransackable_scopes, ransortable_attributes, #to_relation

Methods included from Models::Schedulable

config

Methods included from Models::AfterCommittable

#after_commit

Methods included from Models::EventPublishable

#publish_event

Instance Attribute Details

#attachmentObject (readonly)



148
# File 'app/models/upload.rb', line 148

validates :attachment, presence: true

#importObject

Returns the value of attribute import.



136
137
138
# File 'app/models/upload.rb', line 136

def import
  @import
end

#local_file_pathObject

Returns the value of attribute local_file_path.



136
137
138
# File 'app/models/upload.rb', line 136

def local_file_path
  @local_file_path
end

#should_destroyObject

Returns the value of attribute should_destroy.



136
137
138
# File 'app/models/upload.rb', line 136

def should_destroy
  @should_destroy
end

#templateObject (readonly)



154
# File 'app/models/upload.rb', line 154

validates :template, presence: true, if: proc { |u| u.category == 'installation_plan_image' }

Class Method Details

.all_categories_for_selectObject



187
188
189
# File 'app/models/upload.rb', line 187

def self.all_categories_for_select
  CATEGORIES.sort.map { |c| [c.to_s.humanize.titleize, c.to_s] }
end

.custom_ship_labelsActiveRecord::Relation<Upload>

A relation of Uploads that are custom ship labels. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Upload>)

See Also:



161
# File 'app/models/upload.rb', line 161

scope :custom_ship_labels, -> { where(category: %w[master_carton_label manual_ship_label]) }

.has_document_date?(resource_type) ⇒ Boolean

Returns:

  • (Boolean)


671
672
673
# File 'app/models/upload.rb', line 671

def self.has_document_date?(resource_type)
  %w[StatementOfAccount CreditMemo Invoice].include?(resource_type)
end

.has_role_restrictions?(resource_type) ⇒ Boolean

Returns:

  • (Boolean)


663
664
665
# File 'app/models/upload.rb', line 663

def self.has_role_restrictions?(resource_type)
  resource_type == 'Employee'
end

.in_categoryActiveRecord::Relation<Upload>

A relation of Uploads that are in category. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Upload>)

See Also:



158
# File 'app/models/upload.rb', line 158

scope :in_category, ->(cat) { where(category: cat) }

.invalidActiveRecord::Relation<Upload>

A relation of Uploads that are invalid. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Upload>)

See Also:



157
# File 'app/models/upload.rb', line 157

scope :invalid, -> { where("category LIKE 'invalid_%'") }

.literaturesActiveRecord::Relation<Upload>

A relation of Uploads that are literatures. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Upload>)

See Also:



162
# File 'app/models/upload.rb', line 162

scope :literatures, -> { where(type: 'Literature').joins(:publication) }

.most_recent_firstActiveRecord::Relation<Upload>

A relation of Uploads that are most recent first. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Upload>)

See Also:



159
# File 'app/models/upload.rb', line 159

scope :most_recent_first, -> { reorder('uploads.created_at DESC') }

.options_for_categories_select(class_name, include_other: true, exclude: []) ⇒ Object



448
449
450
451
452
453
454
455
456
457
458
459
460
# File 'app/models/upload.rb', line 448

def self.options_for_categories_select(class_name, include_other: true, exclude: [])
  categories = []
  if class_name.present?
    symbolized_class_name = class_name if class_name.is_a?(Symbol)
    symbolized_class_name ||= class_name.tableize.singularize.to_sym
    new_categories = MODEL_TO_CATEGORIES[symbolized_class_name]
    categories += new_categories if new_categories.present?
  end
  categories += [:other] if include_other
  categories.reject! { |cat| cat.in?(exclude) }
  categories.reject! { |cat| cat.in?(EXCLUDE_CATEGORIES_FOR_MANUAL_UPLOAD) }
  categories.uniq.map { |cat| [cat.to_s.humanize.titleize.gsub('Custom', 'This is public and always enclosed: * Custom'), cat] }.sort_by { |cat_arr| cat_arr.last == :purchase_order ? '0' : cat_arr.first }
end

.sorted_by_positionActiveRecord::Relation<Upload>

A relation of Uploads that are sorted by position. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Upload>)

See Also:



160
# File 'app/models/upload.rb', line 160

scope :sorted_by_position, -> { reorder('uploads.position, uploads.created_at DESC') }

.temp_location(filename) ⇒ Pathname

A scratch path for +filename+, unique per call.

The BASENAME IS LEFT ALONE and the uniqueness lives in a fresh parent
directory, because Dragonfly derives +attachment_name+ from the file it is
assigned — prefixing the file itself would surface a1b2c3_invoice.pdf to
anyone downloading it.

Previously this returned tmp/storage/<filename>: one directory keyed on
the filename alone, shared by every process on the box. Two uploads with the
same basename got the same path, so one overwrote the other's bytes between
write and read — and #set_local_file_path then cached that path, so #to_file
served the wrong file for the rest of the process. Carrier label names are
formulaic, so concurrent shipments could swap PDFs. It also made the test
suite flaky: five tests used 'spec.pdf' and clobbered each other across
parallel workers (CI run 31016240508).

The name is reduced to a BASENAME and sanitized before use. Isolation is
the whole point of this method, and a caller passing ../label.pdf would
climb straight back out of its private directory — uploadify_from_url takes
its file_name from a remote source and never sanitized it (only
uploadify_from_data did), so the guard belongs here, at the one choke point
every caller shares.

Parameters:

  • filename (String)

    the desired name; directory components are stripped

Returns:

  • (Pathname)


425
426
427
428
429
430
431
432
433
434
435
# File 'app/models/upload.rb', line 425

def self.temp_location(filename)
  safe_name = ActiveStorage::Filename.new(File.basename(filename.to_s)).sanitized
  # basename strips '../label.pdf' to 'label.pdf', but a BARE '..' survives
  # both it and sanitized — and `uuid/..` is the scratch root, i.e. exactly
  # the shared directory this method exists to get away from.
  safe_name = 'file' if safe_name.blank? || ['.', '..'].include?(safe_name)

  tmp_dir = Rails.application.config.x.temp_storage_path.join(SecureRandom.uuid)
  FileUtils.mkdir_p(tmp_dir)
  tmp_dir.join(safe_name)
end

.under_temp_storage?(path) ⇒ Boolean

True when +path+ is already inside the scratch tree, so it can be cached
in place rather than copied. Containment, not an exact dirname match —
temp_location nests each file in its own directory. Both sides are expanded
first so .. segments and symlinked roots can't fake containment.

Parameters:

  • path (String, Pathname)

Returns:

  • (Boolean)


443
444
445
446
# File 'app/models/upload.rb', line 443

def self.under_temp_storage?(path)
  root = File.expand_path(Rails.application.config.x.temp_storage_path)
  File.expand_path(path.to_s).start_with?("#{root}#{File::SEPARATOR}")
end

.uploadify(path, category, resource = nil, filename = nil, _do_not_delete = false, expiration_date = nil) ⇒ Object

Raises:

  • (ArgumentError)


359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
# File 'app/models/upload.rb', line 359

def self.uploadify(path, category, resource = nil, filename = nil, _do_not_delete = false, expiration_date = nil)
  raise ArgumentError, "Upload path is nil (category: #{category}, resource: #{resource&.class&.name} ##{resource&.id})" if path.nil?

  if URI(path.to_s).host.present?
    uploadify_from_url(file_name: filename, url: path, category:, resource:, expiration_date:)
  elsif File.exist?(path)
    upload = new(category:, resource:, expiration_date:)
    upload.attachment_name ||= filename
    upload.attachment = File.new(path)
    upload.save!
    # We store a local path to speed up when the upload is called within the same thread
    upload.set_local_file_path(path)
    upload
  else
    msg = "File is missing at #{path}, category: #{category}, resource: #{resource&.class&.name} #{resource&.id}"
    raise StandardError, msg
  end
end

.uploadify_from_data(file_name:, data:, category:, resource: nil) ⇒ Object



378
379
380
381
382
383
384
385
386
387
# File 'app/models/upload.rb', line 378

def self.uploadify_from_data(file_name:, data:, category:, resource: nil)
  file_name = ActiveStorage::Filename.new(file_name).sanitized
  file_path = temp_location(file_name)
  File.open(file_path, 'wb') do |file|
    file.write data
    file.flush
    file.fsync
  end
  uploadify(file_path, category, resource, file_name)
end

.uploadify_from_url(file_name:, url:, category:, resource: nil, _expiration_date: nil, headers: nil) ⇒ Object



389
390
391
392
393
394
395
396
397
398
# File 'app/models/upload.rb', line 389

def self.uploadify_from_url(file_name:, url:, category:, resource: nil, _expiration_date: nil, headers: nil)
  require 'down'
  file_path = temp_location(file_name)
  headers = headers.stringify_keys if headers.present?
  headers ||= {}
  Retryable.retryable(tries: 3, sleep: ->(n) { 4**n }, on: Retryable::TIMEOUT_CLASSES) do |_attempt_number, _exception|
    Down.download(url, headers:, destination: file_path) { |client| client.timeout(read: 60) }
  end
  uploadify(file_path, category, resource, file_name)
end

.validActiveRecord::Relation<Upload>

A relation of Uploads that are valid. Active Record Scope

Returns:

  • (ActiveRecord::Relation<Upload>)

See Also:



156
# File 'app/models/upload.rb', line 156

scope :valid, -> { where("category NOT LIKE 'invalid_%'") }

Instance Method Details

#active_download_token(expires_at = nil) ⇒ Object



195
196
197
198
199
# File 'app/models/upload.rb', line 195

def active_download_token(expires_at = nil)
  dt = download_tokens.where('expires_at IS NULL or expires_at > ?', Time.current).order(Arel.sql('expires_at IS NOT NULL, expires_at DESC')).first
  dt ||= create_new_download_token(expires_at)
  dt.token
end

#articlesActiveRecord::Relation<Article>

Returns:

  • (ActiveRecord::Relation<Article>)

See Also:



114
# File 'app/models/upload.rb', line 114

has_and_belongs_to_many :articles

#attachment_extObject



528
529
530
# File 'app/models/upload.rb', line 528

def attachment_ext
  File.extname(attachment_name).downcase.delete_prefix('.') if attachment_name.present?
end

#browser_previewable?Boolean

True for file types modern browsers can render natively (PDFs and images).
Used by the show action to decide whether to serve inline or force download.

Returns:

  • (Boolean)


542
543
544
# File 'app/models/upload.rb', line 542

def browser_previewable?
  is_pdf? || is_image?
end

#cad_category?Boolean

Returns:

  • (Boolean)


659
660
661
# File 'app/models/upload.rb', line 659

def cad_category?
  category.in?(%w[room_layout_cad installation_plan_cad tracer_solution_cad])
end

#clone_with_file(new_resource = nil) ⇒ Object



305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
# File 'app/models/upload.rb', line 305

def clone_with_file(new_resource = nil)
  new_upload = nil
  return if attachment.blank?

  begin
    new_upload = Upload.new(category:,
                            resource: new_resource,
                            template:,
                            note:)
    new_upload.attachment_url = presigned_url(expires_in: 10.minutes)
    new_upload.attachment_name = attachment_name
    new_upload.attachment_mime_type = mime_type
  rescue StandardError => e
    Rails.logger.error "File could not be found for #{id}, #{e}"
    new_upload = nil
  end
  new_upload
end

#communicationsActiveRecord::Relation<Communication>

Returns:

See Also:



113
# File 'app/models/upload.rb', line 113

has_and_belongs_to_many :communications

#create_new_download_token(expires_at = nil) ⇒ Object



191
192
193
# File 'app/models/upload.rb', line 191

def create_new_download_token(expires_at = nil)
  download_tokens.create(expires_at:)
end

#deletable?Boolean

Returns:

  • (Boolean)


485
486
487
488
489
490
491
# File 'app/models/upload.rb', line 485

def deletable?
  if resource_type == 'RoomConfiguration'
    !resource.complete?
  else
    false
  end
end

#determine_orientationObject



225
226
227
228
229
230
231
232
# File 'app/models/upload.rb', line 225

def determine_orientation
  case template
  when /landscape/
    'Landscape'
  else
    'Portrait'
  end
end

#determine_page_sizeObject



210
211
212
213
214
215
216
217
218
219
# File 'app/models/upload.rb', line 210

def determine_page_size
  case template
  when /tabloid/
    'Tabloid'
  when /plotter/
    'Plotter'
  else
    'Letter'
  end
end

#download_tokensActiveRecord::Relation<DownloadToken>

Returns:

See Also:



111
# File 'app/models/upload.rb', line 111

has_many :download_tokens, inverse_of: :upload

#dup_with_attachmentUpload

STI-preserving duplicate with its own stored copy of the attachment: the
bytes are re-stored under a new uid, so the copy never aliases the
original's dragonfly uid (which would let one row's destroy pull the file
from under the other). Used to keep a publication's literature 1:1 when the
publication is duplicated (see Item#deep_dup).

The copy is built from +attributes+, NOT #dup: #dup copies the memoized
dragonfly attachment ivar, and with the source's proxy aboard, assignment
on the copy mis-stores — a second dup_with_attachment on the same source
produced rows carrying the source's uid (or no stored bytes at all) in
production. self.class.new(attrs) carries no instance state.

+extracted_markdown+ is intentionally not carried: clear_stale_extracted_markdown
nils it on save (attachment_uid changed), and the PublicationPdfChanged
pipeline re-extracts asynchronously.

Returns:

  • (Upload)

    the unsaved copy (belongs_to autosave persists it with the owner)



341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
# File 'app/models/upload.rb', line 341

def dup_with_attachment
  copy = self.class.new(attributes.except('id', 'attachment_uid', 'created_at', 'updated_at'))
  if attachment_stored?
    # A raw-string assignment leaves the dragonfly content nameless (and the
    # after_assign sanitizer requires a name), so round-trip through a temp
    # file named like the original — the uploadify_from_data pattern. The
    # unique subdirectory keeps concurrent dups of a same-named attachment
    # from overwriting each other before dragonfly stores the bytes (the
    # basename stays the original, so the stored uid keeps its name shape).
    dir = self.class.temp_location(SecureRandom.hex(8))
    FileUtils.mkdir_p(dir)
    file_path = dir.join(attachment_name.presence || 'attachment')
    File.binwrite(file_path, attachment.data)
    copy.attachment = File.new(file_path)
  end
  copy
end

#extract_markdown!(force: false) ⇒ String

Extract the attachment to layout-aware Markdown via DoclingClient and
cache it on +extracted_markdown+. Publications/literatures get this
automatically on PDF link/replace (Publication::MarkdownExtractionHandler);
anything else on demand (Sunny's +extract_document+ tool,
UploadMarkdownExtractionWorker).

Parameters:

  • force (Boolean) (defaults to: false)

    re-extract even when a cached extraction exists

Returns:

  • (String)

    the Markdown

Raises:



276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
# File 'app/models/upload.rb', line 276

def extract_markdown!(force: false)
  # nil = never extracted (or invalidated); "" is a cached empty result and
  # must NOT re-trigger a conversion round-trip on every call.
  return extracted_markdown unless extracted_markdown.nil? || force

  path     = to_file
  markdown = DoclingClient.convert(path, filename: attachment_name.presence || File.basename(path))
  # Postgres text columns cannot store NUL (\u0000); docling occasionally emits
  # it from binary/garbled PDF spans, which made update_column raise
  # "ArgumentError: string contains null byte" (AppSignal #6330). Strip it.
  markdown = markdown.delete("\u0000") if markdown&.include?("\u0000")
  # update_column: derived cache — skip validations (legacy rows can fail
  # category/property checks) and the audit trail.
  update_column(:extracted_markdown, markdown)
  markdown
end

#file_exists?Boolean

Returns:

  • (Boolean)


475
476
477
478
479
480
481
482
483
# File 'app/models/upload.rb', line 475

def file_exists?
  return false if attachment.nil?

  signed_url = presigned_url(expires_in: 60.minutes)
  response = HTTP.headers('Range' => 'bytes=0-0').get(signed_url)
  response.status.success?
rescue HTTP::Error, Dragonfly::Job::Fetch::NotFound
  false
end

#file_name_for_downloadObject



221
222
223
# File 'app/models/upload.rb', line 221

def file_name_for_download
  attachment_name # Literature subclass specializes this
end

#get_local_file_pathObject

Returns the local file path but only if the file still exists on disk.
Checks the instance attr first, then falls back to the process-level cache
so that freshly loaded instances of the same record can reuse temp files.



682
683
684
685
686
687
688
689
690
691
692
# File 'app/models/upload.rb', line 682

def get_local_file_path
  path = local_file_path || (id && CurrentPaths[id])
  if path.present? && File.exist?(path.to_s)
    self.local_file_path = path
    path
  else
    self.local_file_path = nil
    CurrentPaths.delete(id) if id
    nil
  end
end

#has_document_date?Boolean

Returns:

  • (Boolean)


675
676
677
# File 'app/models/upload.rb', line 675

def has_document_date?
  self.class.has_document_date?(resource_type)
end

#has_role_restrictions?Boolean

Returns:

  • (Boolean)


667
668
669
# File 'app/models/upload.rb', line 667

def has_role_restrictions?
  self.class.has_role_restrictions?(resource_type)
end

#has_thumbnail?Boolean

Returns:

  • (Boolean)


546
547
548
# File 'app/models/upload.rb', line 546

def has_thumbnail?
  is_image_or_pdf?
end

#image_attachment(dimensions) ⇒ Object



550
551
552
553
554
555
556
# File 'app/models/upload.rb', line 550

def image_attachment(dimensions)
  if is_image?
    attachment.thumb(dimensions)
  elsif is_pdf? # pdf
    pdf_thumbnail_attachment(dimensions)
  end
end

#image_category?Boolean

Returns:

  • (Boolean)


655
656
657
# File 'app/models/upload.rb', line 655

def image_category?
  category.in?(%w[installation_plan_image tracer_solution_image])
end

#image_url(dimensions) ⇒ Object



558
559
560
# File 'app/models/upload.rb', line 558

def image_url(dimensions)
  image_attachment(dimensions)&.url
end

#is_audio?Boolean

Returns:

  • (Boolean)


512
513
514
# File 'app/models/upload.rb', line 512

def is_audio?
  mime_type&.start_with?('audio/')
end

#is_image?Boolean

Returns:

  • (Boolean)


520
521
522
# File 'app/models/upload.rb', line 520

def is_image?
  mime_type&.start_with?('image/') || (undefined_mime_type? && IMAGE_EXTENSIONS.include?(attachment_ext))
end

#is_image_or_pdf?Boolean

Returns:

  • (Boolean)


524
525
526
# File 'app/models/upload.rb', line 524

def is_image_or_pdf?
  is_image? || is_pdf?
end

#is_mp3?Boolean

Returns:

  • (Boolean)


507
508
509
510
# File 'app/models/upload.rb', line 507

def is_mp3?
  (mime_type =~ %r{^audio/(?:mp3|mpeg|mpeg3|mpg|x-mp3|x-mpeg|x-mpeg3|x-mpegaudio|x-mpg)$}).present? ||
    (undefined_mime_type? && attachment_ext == 'mp3')
end

#is_pdf?Boolean

Returns:

  • (Boolean)


536
537
538
# File 'app/models/upload.rb', line 536

def is_pdf?
  mime_type == 'application/pdf' || (undefined_mime_type? && attachment_ext == 'pdf')
end

#is_video?Boolean

Returns:

  • (Boolean)


516
517
518
# File 'app/models/upload.rb', line 516

def is_video?
  mime_type&.start_with?('video/')
end

#is_wav?Boolean

Returns:

  • (Boolean)


502
503
504
505
# File 'app/models/upload.rb', line 502

def is_wav?
  ['audio/wav', 'audio/x-wav'].include?(mime_type) ||
    (undefined_mime_type? && attachment_ext == 'wav')
end

#itemsActiveRecord::Relation<Item>

Returns:

  • (ActiveRecord::Relation<Item>)

See Also:



116
# File 'app/models/upload.rb', line 116

has_and_belongs_to_many :items

#mime_typeObject



493
494
495
496
497
498
499
500
# File 'app/models/upload.rb', line 493

def mime_type
  return attachment_mime_type if attachment_mime_type.present?

  # Since we disabled automatic MIME type detection in Dragonfly,
  # we should rely on the stored attachment_mime_type field
  # This avoids triggering file downloads from S3
  attachment_mime_type
end

#mime_type_symbolObject



639
640
641
642
643
# File 'app/models/upload.rb', line 639

def mime_type_symbol
  attachment.ext.to_sym
rescue StandardError
  nil
end

#ok_to_delete?Boolean

Returns:

  • (Boolean)


645
646
647
648
649
# File 'app/models/upload.rb', line 645

def ok_to_delete?
  # Check if this upload is used as literature for any publication (Item)
  # Use direct database query to avoid association caching issues
  !Item.exists?(literature_id: id) && resource.nil?
end

#options_for_categories_select(include_other: true, exclude: []) ⇒ Object



468
469
470
471
472
473
# File 'app/models/upload.rb', line 468

def options_for_categories_select(include_other: true, exclude: [])
  class_name_to_use = resource&.class&.name
  # When upload are used with Item, the resource is nil
  class_name_to_use ||= :item if items.present?
  self.class.options_for_categories_select(class_name_to_use, include_other:, exclude:)
end

#pdf_category?Boolean

Returns:

  • (Boolean)


651
652
653
# File 'app/models/upload.rb', line 651

def pdf_category?
  category.in?(%w[custom_packing_slip_pdf purchase_order])
end

#pdf_thumbnail_attachment(dimensions = '600x600>', density: 150) ⇒ Object



562
563
564
565
566
567
568
569
# File 'app/models/upload.rb', line 562

def pdf_thumbnail_attachment(dimensions = '600x600>', density: 150)
  return unless is_pdf?

  # Using libvips options: page (first page), dpi for resolution
  # 150 DPI is sufficient for thumbnails up to ~1200px (8.5" * 150 = 1275px)
  # AVIF output for best compression/quality ratio; Cloudflare handles browser fallback
  attachment.thumb(dimensions, format: :avif, input_options: { page: 0, dpi: density })
end

#pdf_thumbnail_url(dimensions = '600x600>', density: 400) ⇒ Object



571
572
573
574
575
# File 'app/models/upload.rb', line 571

def pdf_thumbnail_url(dimensions = '600x600>', density: 400)
  return unless is_pdf?

  pdf_thumbnail_attachment(dimensions, density:).url
end

#presigned_url(expires_in: 1.day, download: false, file_name: nil) ⇒ Object

Generate a presigned URL for the attachment locally without S3 network calls.
Uses Dragonfly's datastore.url_for which is purely local AWS Sig V4 computation.
Use this for preview/download links where you need a signed URL quickly.



592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
# File 'app/models/upload.rb', line 592

def presigned_url(expires_in: 1.day, download: false, file_name: nil)
  return nil unless attachment_uid.present? && attachment.present?

  # AWS SigV4 (and Wasabi) reject a presigned URL whose lifetime isn't
  # strictly under 7 days (604,800s) with 400 AuthorizationQueryParametersError.
  # Clamp so callers asking for a longer window (e.g. 30.days) still get a
  # working URL instead of a broken one. Links that must outlive a week have
  # to re-sign on each click via a redirect, not bake one long-lived URL.
  max_expires_in = 7.days - 1.hour
  expires_in = max_expires_in if expires_in.to_i > max_expires_in.to_i

  query_params = {}
  if download || file_name.present?
    safe_filename = (file_name || attachment_name).to_s.parameterize(separator: '_')
    safe_filename = "#{safe_filename}#{File.extname(attachment_name)}" unless safe_filename.include?('.')
    disposition = download ? 'attachment' : 'inline'
    query_params['response-content-disposition'] = "#{disposition}; filename=\"#{safe_filename}\""
  end

  # Get datastore from the attachment's app (not hardcoded)
  attachment.app.datastore.url_for(
    attachment_uid,
    scheme: 'https',
    expires: expires_in.from_now.to_i,
    query: query_params.presence
  )
end

#publicationItem

Returns:

See Also:



98
# File 'app/models/upload.rb', line 98

has_one :publication, class_name: 'Item', inverse_of: :literature, foreign_key: :literature_id

#recipient_party_idObject



620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
# File 'app/models/upload.rb', line 620

def recipient_party_id
  p = begin
    resource.customer_id
  rescue StandardError
    nil
  end
  p ||= begin
    resource.party_id
  rescue StandardError
    nil
  end
  p ||= begin
    upload.resource.supplier_id
  rescue StandardError
    nil
  end
  p
end

#resourceResource

Returns:

  • (Resource)

See Also:



97
# File 'app/models/upload.rb', line 97

belongs_to :resource, polymorphic: true, optional: true

#resource_comboObject



293
294
295
# File 'app/models/upload.rb', line 293

def resource_combo
  "#{resource_type}|#{resource_id}" if resource
end

#resource_combo=(combo_val) ⇒ Object



297
298
299
300
301
302
303
# File 'app/models/upload.rb', line 297

def resource_combo=(combo_val)
  return if combo_val.blank?

  values = combo_val.split('|')
  self.resource_type = values[0]
  self.resource_id = values[1]
end

#rolesActiveRecord::Relation<Role>

Returns:

  • (ActiveRecord::Relation<Role>)

See Also:



115
# File 'app/models/upload.rb', line 115

has_and_belongs_to_many :roles

#scope_conditionObject

Uploads should only be sorted based on the presence of resource id of category
otherwise if those are null then the whole upload table gets a shuffle. big
performance hit and deadlocks
:resource_id, :category



170
171
172
173
174
175
176
177
178
179
180
181
# File 'app/models/upload.rb', line 170

def scope_condition
  if resource_type && resource_id
    cnds = {
      resource_type:,
      resource_id:
    }
    cnds[:category] = category if category.present?
    cnds
  else
    { id: -1 }
  end
end

#set_local_file_path(file_path) ⇒ Object

Ensures our local file path is stored in the temp storage directory (for
sendfile header acceleration) and registers it in the request-scoped cache
so other instances of the same record can find it without hitting S3.



697
698
699
700
701
702
703
704
705
706
707
708
709
710
# File 'app/models/upload.rb', line 697

def set_local_file_path(file_path)
  if self.class.under_temp_storage?(file_path)
    self.local_file_path = file_path
  else
    # Through temp_location so the copy lands in its own directory. The old
    # `temp_storage_path.join(File.basename(...))` put every upload sharing a
    # basename on ONE cached path, which is what let #to_file serve another
    # upload's bytes.
    self.local_file_path = self.class.temp_location(File.basename(file_path))
    FileUtils.cp(file_path, local_file_path.to_s)
  end
  CurrentPaths[id] = local_file_path.to_s if id.present?
  local_file_path
end

#template_options_for_selectObject



462
463
464
465
466
# File 'app/models/upload.rb', line 462

def template_options_for_select
  return [] unless resource.respond_to?(:template_options_for_select)

  resource.template_options_for_select
end

#thumbnail_2x_url(dimensions: '184x122>') ⇒ Object



583
584
585
586
587
# File 'app/models/upload.rb', line 583

def thumbnail_2x_url(dimensions: '184x122>')
  return unless has_thumbnail?

  image_attachment(dimensions)&.url
end

#thumbnail_url(dimensions: '92x61>') ⇒ Object



577
578
579
580
581
# File 'app/models/upload.rb', line 577

def thumbnail_url(dimensions: '92x61>')
  return unless has_thumbnail?

  image_attachment(dimensions)&.url
end

#titleObject



205
206
207
208
# File 'app/models/upload.rb', line 205

def title
  # this is to support serialization of Installation Plan Vignettes
  category.humanize.titleize.to_s
end

#to_file(file_path: nil, file_name: nil) ⇒ Object

Downloads the file using the Down library to a temporary place



235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
# File 'app/models/upload.rb', line 235

def to_file(file_path: nil, file_name: nil)
  # If we still have a local copy of the file we will return this right away
  if get_local_file_path
    Rails.logger.info "to_file getting upload #{id} from local path: #{get_local_file_path}"
    # If file_path specified, copy the file to that location
    return get_local_file_path unless file_path.present? && file_path.to_s != get_local_file_path

    FileUtils.cp get_local_file_path, file_path
    file_path

  else
    remote_url = presigned_url(expires_in: 15.minutes)
    Rails.logger.info "to_file getting upload #{id} from remote path #{remote_url}"
    # Is there an extension to this upload?
    remote_file_name = attachment_name.presence || URI.parse(url).path
    ext = begin
      File.extname(remote_file_name)
    rescue StandardError
      nil
    end
    # Future you can also look at the response headers using a chunked io download
    file_name ||= "tmp_#{id}_#{Time.current.strftime('%Y-%m-%d-%H%M%S')}#{ext}"
    file_path ||= Rails.application.config.x.temp_storage_path.join(file_name)
    Retryable.retryable(tries: 3, sleep: ->(n) { 4**n }, on: Retryable::TIMEOUT_CLASSES) do |attempt_number, exception|
      ErrorReporting.error(exception, message: "Exception on download attempt #{attempt_number} for #{remote_url}") if exception
      Down::Http.download(remote_url, destination: file_path) { |client| client.timeout(read: 30) }
    end
    set_local_file_path(file_path) # Speed up further retrievals
  end
  file_path.to_s
end

#to_sObject



201
202
203
# File 'app/models/upload.rb', line 201

def to_s
  attachment_name || "Upload id #{id}"
end

#undefined_mime_type?Boolean

Returns:

  • (Boolean)


532
533
534
# File 'app/models/upload.rb', line 532

def undefined_mime_type?
  mime_type.blank? || mime_type == 'application/octet-stream'
end