Class: GeneratedPdf

Inherits:
ApplicationRecord show all
Defined in:
app/models/generated_pdf.rb

Overview

An AI-generated / reconstructed PDF staging record — the PDF analogue of
GeneratedImage. Created by +PdfGenerationWorker+ (or the Sunny pdf_generate
tool) after a successful render, pending user review in the PDF studio.

The user reviews the staged PDF (generated_pdfs#show) where they can:

  • Edit the +layout+ and regenerate (iterate)
  • Import it into the publication library — promotes to a Literature upload +
    publication Item via GeneratedPdfImporter (which also persists +layout+
    onto the Literature's +source_layout+ so it can be re-opened later)
  • Discard it

Stale pending records (> 48 h) are reaped by +GeneratedPdfCleanupWorker+.

The staged PDF bytes live in the Dragonfly +:secure+ store (same store as
Upload), so promotion re-materializes them into a real Literature upload.
The +layout+ JSONB is the declarative source-of-truth for iteration.

== Schema Information

Table name: generated_pdfs
Database name: primary

id :bigint not null, primary key
byte_size :integer
content_type :string default("application/pdf"), not null
error :text
file_name :string
file_uid :string
instructions :text
kind :string default("generated"), not null
layout :jsonb not null
page_count :integer
source_record_type :string
status :string default("pending"), not null
suggested_title :string
title :string
created_at :datetime not null
updated_at :datetime not null
created_by_id :bigint
source_publication_id :bigint
source_record_id :bigint

Constant Summary collapse

STATUSES =

Statuses.

%w[pending imported rejected].freeze
KINDS =

Kinds — how the PDF was produced. translated = language variant of a
source publication (provider provenance in layout jsonb, imported with locales:).

%w[generated edited reconstructed translated].freeze
STALE_AFTER =

Stale after.

48.hours
SKU_LANG_MARKERS_RE =

Language/region markers already present in SKUs (stripped before inserting
the target locale, so re-translating a translation doesn't stack markers).

/-(?:FRENCH|SPANISH|GERMAN|ITALIAN|DUTCH|POLISH|SWEDISH|FRANCAIS|ESPAGNOL|CANADA|CANADIAN|USA|CA|EN-US|EN-CA|FR-CA|FR|ES|DE|IT|NL|PL|SV)(?=-[A-Z]$|$)/

Constants included from Models::Schedulable

Models::Schedulable::SIMPLE_FORM_OPTIONS

Instance Attribute Summary collapse

Belongs to collapse

Class Method Summary collapse

Instance Method Summary collapse

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

#kindObject (readonly)

Validates kind.

Validations:



66
# File 'app/models/generated_pdf.rb', line 66

validates :kind,   inclusion: { in: KINDS }

#statusObject (readonly)

Validates status.

Validations:



64
# File 'app/models/generated_pdf.rb', line 64

validates :status, inclusion: { in: STATUSES }

Class Method Details

.importedActiveRecord::Relation<GeneratedPdf>

A relation of GeneratedPdfs that are imported. Active Record Scope

Returns:

See Also:



69
# File 'app/models/generated_pdf.rb', line 69

scope :imported, -> { where(status: 'imported') }

.pendingActiveRecord::Relation<GeneratedPdf>

A relation of GeneratedPdfs that are pending. Active Record Scope

Returns:

See Also:



68
# File 'app/models/generated_pdf.rb', line 68

scope :pending,  -> { where(status: 'pending') }

.rejectedActiveRecord::Relation<GeneratedPdf>

A relation of GeneratedPdfs that are rejected. Active Record Scope

Returns:

See Also:



70
# File 'app/models/generated_pdf.rb', line 70

scope :rejected, -> { where(status: 'rejected') }

.staleActiveRecord::Relation<GeneratedPdf>

A relation of GeneratedPdfs that are stale. Active Record Scope

Returns:

See Also:



71
# File 'app/models/generated_pdf.rb', line 71

scope :stale,    -> { pending.where(created_at: ...STALE_AFTER.ago) }

Instance Method Details

#assign_pdf(bytes, filename:) ⇒ Object

Set the staged PDF content and derive byte size in one call. Mirrors
+Upload.uploadify_from_data+: the custom dragonfly_accessor's after_assign
hook needs a named file (a raw string has no name), so write a temp file and
assign it. The temp file is read by Dragonfly at save time.

Parameters:

  • bytes (String)

    raw PDF data

  • filename (String)


156
157
158
159
160
161
162
163
164
165
166
# File 'app/models/generated_pdf.rb', line 156

def assign_pdf(bytes, filename:)
  name = filename.to_s
  name = "#{name}.pdf" unless name.downcase.end_with?('.pdf')
  path = Upload.temp_location("genpdf_#{SecureRandom.hex(8)}_#{File.basename(name)}")
  File.binwrite(path, bytes)

  self.file         = File.new(path)
  self.file_name    = name # normalize (after_assign sets the temp basename)
  self.content_type = 'application/pdf'
  self.byte_size    = bytes.bytesize
end

#created_byAccount?

Returns the created by this record belongs to.

Returns:

  • (Account, nil)

    the created by this record belongs to



53
# File 'app/models/generated_pdf.rb', line 53

belongs_to :created_by,         class_name: 'Account', optional: true

#derived_translation_skuString?

Proposed SKU for a +translated+ staged PDF: the source SKU with the target
locale inserted before the revision letter, bumped (A → B → …) until
unique across publications.
TZ-MANUAL-A (fr) → TZ-MANUAL-FR-A
X-SELL-A (fr-CA) → X-SELL-FR-CA-A (taken) → X-SELL-FR-CA-B

Returns:

  • (String, nil)

    nil when not a translation, has no source, or the
    alphabet ran out (the import form falls back to its generic derivation)



107
108
109
110
111
112
113
114
115
116
117
118
119
120
# File 'app/models/generated_pdf.rb', line 107

def derived_translation_sku
  return nil unless kind == 'translated'

  source = source_publication
  return nil unless source

  marker = translation_lang_out.to_s.upcase.presence || 'INTL'
  base   = source.sku.to_s.sub(SKU_LANG_MARKERS_RE, '').sub(/-[A-Z]$/, '')
  ('A'..'Z').each do |letter|
    candidate = "#{base}-#{marker}-#{letter}"
    return candidate unless Item.publications.exists?(sku: candidate)
  end
  nil
end

#file_stored?Boolean

Returns whether the record file stored.

Returns:

  • (Boolean)

    whether the record file stored



146
147
148
# File 'app/models/generated_pdf.rb', line 146

def file_stored?
  file_uid.present? && file.present?
end

#file_urlObject

App-served URL for embedding the staged PDF in the review viewer.



139
140
141
142
143
# File 'app/models/generated_pdf.rb', line 139

def file_url
  file&.url
rescue StandardError
  nil
end

#imported?Boolean

Returns whether the record imported.

Returns:

  • (Boolean)

    whether the record imported



76
77
# File 'app/models/generated_pdf.rb', line 76

def imported? = status == 'imported'
# @return [Boolean] whether the record rejected

#pdf_bytesObject

The staged PDF as raw bytes (nil if nothing stored).



123
124
125
# File 'app/models/generated_pdf.rb', line 123

def pdf_bytes
  file&.data
end

#pending?Boolean

Returns whether the record pending.

Returns:

  • (Boolean)

    whether the record pending



74
75
# File 'app/models/generated_pdf.rb', line 74

def pending?  = status == 'pending'
# @return [Boolean] whether the record imported

#rejected?Boolean

Returns whether the record rejected.

Returns:

  • (Boolean)

    whether the record rejected



78
# File 'app/models/generated_pdf.rb', line 78

def rejected? = status == 'rejected'

#source_publicationItem?

Returns the source publication this record belongs to.

Returns:

  • (Item, nil)

    the source publication this record belongs to



49
# File 'app/models/generated_pdf.rb', line 49

belongs_to :source_publication, class_name: 'Item', optional: true

#source_recordSourceRecord?

Returns the source record this record belongs to.

Returns:

  • (SourceRecord, nil)

    the source record this record belongs to



51
# File 'app/models/generated_pdf.rb', line 51

belongs_to :source_record,      polymorphic: true,  optional: true

#to_temp_pathString?

Materialize the staged PDF to a local temp path (for re-processing / import).
Caller is responsible for deleting the returned path.

Returns:

  • (String, nil)


130
131
132
133
134
135
136
# File 'app/models/generated_pdf.rb', line 130

def to_temp_path
  return nil unless file_stored?

  path = Rails.application.config.x.temp_storage_path.join("generated_pdf_#{id || SecureRandom.hex(4)}.pdf")
  File.binwrite(path, file.data)
  path.to_s
end

#translated_base_nameString?

The LLM-translated publication base name staged by
PublicationTranslationWorker (+translated+ kind only) — the import form
pre-fills this as the publication name so the catalog name and slug land
in the target language.

Returns:

  • (String, nil)


91
92
93
# File 'app/models/generated_pdf.rb', line 91

def translated_base_name
  layout.to_h.dig('translation', 'translated_base_name')
end

#translation_lang_outString?

Target locale staged by PublicationTranslationWorker (+translated+ kind only).

Returns:

  • (String, nil)

    e.g. 'fr-CA'



82
83
84
# File 'app/models/generated_pdf.rb', line 82

def translation_lang_out
  layout.to_h.dig('translation', 'lang_out')
end