add audit trail
This commit is contained in:
+1
-1
@@ -31,7 +31,7 @@ ENV BUNDLE_WITHOUT="development:test"
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler vips-heif libc6-compat ttf-freefont ttf-liberation && cp /usr/share/fonts/liberation/LiberationSans-Regular.ttf /LiberationSans-Regular.ttf && apk del ttf-liberation
|
RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler vips-heif libc6-compat ttf-freefont ttf-liberation && cp /usr/share/fonts/liberation/LiberationSans-Regular.ttf /usr/share/fonts/liberation/LiberationSans-Bold.ttf / && apk del ttf-liberation
|
||||||
|
|
||||||
COPY ./Gemfile ./Gemfile.lock ./
|
COPY ./Gemfile ./Gemfile.lock ./
|
||||||
|
|
||||||
|
|||||||
@@ -15,9 +15,13 @@ class SubmissionsDebugController < ApplicationController
|
|||||||
render 'submit_form/show'
|
render 'submit_form/show'
|
||||||
end
|
end
|
||||||
f.pdf do
|
f.pdf do
|
||||||
Submissions::GenerateResultAttachments.call(@submitter)
|
if params[:audit]
|
||||||
|
Submissions::GenerateAuditTrail.call(@submitter.submission)
|
||||||
|
else
|
||||||
|
Submissions::GenerateResultAttachments.call(@submitter)
|
||||||
|
end
|
||||||
|
|
||||||
send_data ActiveStorage::Attachment.where(name: :documents).last.download,
|
send_data ActiveStorage::Attachment.where(name: params[:audit] ? :audit_trail : :documents).last.download,
|
||||||
filename: 'debug.pdf',
|
filename: 'debug.pdf',
|
||||||
disposition: 'inline',
|
disposition: 'inline',
|
||||||
type: 'application/pdf'
|
type: 'application/pdf'
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ class ProcessSubmitterCompletionJob < ApplicationJob
|
|||||||
return unless is_all_completed
|
return unless is_all_completed
|
||||||
return if submitter.completed_at != submitter.submission.submitters.maximum(:completed_at)
|
return if submitter.completed_at != submitter.submission.submitters.maximum(:completed_at)
|
||||||
|
|
||||||
|
Submissions::GenerateAuditTrail.call(submitter.submission)
|
||||||
|
|
||||||
enqueue_emails(submitter)
|
enqueue_emails(submitter)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -33,11 +33,7 @@ class SubmitterMailer < ApplicationMailer
|
|||||||
|
|
||||||
@email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY)
|
@email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY)
|
||||||
|
|
||||||
documents = Submitters.select_attachments_for_download(submitter)
|
add_completed_email_attachments!(submitter)
|
||||||
|
|
||||||
documents.each do |attachment|
|
|
||||||
attachments[attachment.filename.to_s] = attachment.download
|
|
||||||
end
|
|
||||||
|
|
||||||
subject =
|
subject =
|
||||||
if @email_config
|
if @email_config
|
||||||
@@ -60,11 +56,7 @@ class SubmitterMailer < ApplicationMailer
|
|||||||
|
|
||||||
Submissions::EnsureResultGenerated.call(@submitter)
|
Submissions::EnsureResultGenerated.call(@submitter)
|
||||||
|
|
||||||
@documents = Submitters.select_attachments_for_download(submitter)
|
@documents = add_completed_email_attachments!(submitter)
|
||||||
|
|
||||||
@documents.each do |attachment|
|
|
||||||
attachments[attachment.filename.to_s] = attachment.download
|
|
||||||
end
|
|
||||||
|
|
||||||
@email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY)
|
@email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY)
|
||||||
|
|
||||||
@@ -82,6 +74,20 @@ class SubmitterMailer < ApplicationMailer
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def add_completed_email_attachments!(submitter)
|
||||||
|
documents = Submitters.select_attachments_for_download(submitter)
|
||||||
|
|
||||||
|
documents.each do |attachment|
|
||||||
|
attachments[attachment.filename.to_s] = attachment.download
|
||||||
|
end
|
||||||
|
|
||||||
|
if submitter.submission.audit_trail.present?
|
||||||
|
attachments[submitter.submission.audit_trail.filename.to_s] = submitter.submission.audit_trail.download
|
||||||
|
end
|
||||||
|
|
||||||
|
documents
|
||||||
|
end
|
||||||
|
|
||||||
def from_address_for_submitter(submitter)
|
def from_address_for_submitter(submitter)
|
||||||
submitter.submission.created_by_user&.friendly_name || submitter.submission.template.author.friendly_name
|
submitter.submission.created_by_user&.friendly_name || submitter.submission.template.author.friendly_name
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ class Submission < ApplicationRecord
|
|||||||
attribute :source, :string, default: 'link'
|
attribute :source, :string, default: 'link'
|
||||||
attribute :submitters_order, :string, default: 'random'
|
attribute :submitters_order, :string, default: 'random'
|
||||||
|
|
||||||
|
has_one_attached :audit_trail
|
||||||
|
|
||||||
has_many :template_schema_documents,
|
has_many :template_schema_documents,
|
||||||
->(e) { where(uuid: (e.template_schema.presence || e.template.schema).pluck('attachment_uuid')) },
|
->(e) { where(uuid: (e.template_schema.presence || e.template.schema).pluck('attachment_uuid')) },
|
||||||
through: :template, source: :documents_attachments
|
through: :template, source: :documents_attachments
|
||||||
|
|||||||
@@ -15,4 +15,8 @@ module PdfIcons
|
|||||||
def paperclip_io
|
def paperclip_io
|
||||||
@paperclip_io ||= StringIO.new(PATH.join('paperclip.png').read)
|
@paperclip_io ||= StringIO.new(PATH.join('paperclip.png').read)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def logo_io
|
||||||
|
@logo_io ||= StringIO.new(PATH.join('logo.png').read)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -3,6 +3,17 @@
|
|||||||
module SubmissionEvents
|
module SubmissionEvents
|
||||||
TRACKING_PARAM_LENGTH = 6
|
TRACKING_PARAM_LENGTH = 6
|
||||||
|
|
||||||
|
EVENT_NAMES = {
|
||||||
|
send_email: 'Email sent',
|
||||||
|
send_sms: 'SMS sent',
|
||||||
|
open_email: 'Email opened',
|
||||||
|
click_email: 'Email link clicked',
|
||||||
|
click_sms: 'SMS link clicked',
|
||||||
|
start_form: 'Submission started',
|
||||||
|
view_form: 'Form viewed',
|
||||||
|
complete_form: 'Submission completed'
|
||||||
|
}.freeze
|
||||||
|
|
||||||
module_function
|
module_function
|
||||||
|
|
||||||
def build_tracking_param(submitter, event_type = 'click_email')
|
def build_tracking_param(submitter, event_type = 'click_email')
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Submissions
|
||||||
|
module GenerateAuditTrail
|
||||||
|
FONT_SIZE = 9
|
||||||
|
TEXT_COLOR = '525252'
|
||||||
|
FONT_PATH = '/LiberationSans-Regular.ttf'
|
||||||
|
FONT_BOLD_PATH = '/LiberationSans-Bold.ttf'
|
||||||
|
FONT_NAME = if File.exist?(FONT_PATH)
|
||||||
|
FONT_PATH
|
||||||
|
else
|
||||||
|
'Helvetica'
|
||||||
|
end
|
||||||
|
FONT_BOLD_NAME = if File.exist?(FONT_BOLD_PATH)
|
||||||
|
FONT_BOLD_PATH
|
||||||
|
else
|
||||||
|
'Helvetica'
|
||||||
|
end
|
||||||
|
|
||||||
|
INFO_CREATOR = "#{Docuseal::PRODUCT_NAME} (#{Docuseal::PRODUCT_URL})".freeze
|
||||||
|
SIGN_REASON = 'Signed with DocuSeal.co'
|
||||||
|
|
||||||
|
module_function
|
||||||
|
|
||||||
|
# rubocop:disable Metrics
|
||||||
|
def call(submission)
|
||||||
|
account = submission.template.account
|
||||||
|
pkcs = Accounts.load_signing_pkcs(account)
|
||||||
|
verify_url = Rails.application.routes.url_helpers.settings_esign_url(**Docuseal.default_url_options)
|
||||||
|
|
||||||
|
composer = HexaPDF::Composer.new(skip_page_creation: true)
|
||||||
|
composer.document.fonts.add(FONT_BOLD_NAME, variant: :bold)
|
||||||
|
|
||||||
|
divider = HexaPDF::Layout::Box.create(
|
||||||
|
margin: [0, 0, 15, 0],
|
||||||
|
border: {
|
||||||
|
width: [1, 0, 0, 0],
|
||||||
|
color: %w[hp-gray-light]
|
||||||
|
},
|
||||||
|
height: 1
|
||||||
|
)
|
||||||
|
|
||||||
|
composer.page_style(:default, page_size: :A4) do |canvas, style|
|
||||||
|
box = canvas.context.box(:media)
|
||||||
|
canvas.save_graphics_state do
|
||||||
|
canvas.fill_color('FAF7F5')
|
||||||
|
.rectangle(0, 0, box.width, 20)
|
||||||
|
.rectangle(0, box.height - 20, box.width, 20)
|
||||||
|
.fill
|
||||||
|
end
|
||||||
|
style.frame = style.create_frame(canvas.context, 50)
|
||||||
|
end
|
||||||
|
|
||||||
|
composer.style(:base, font: FONT_NAME, font_size: FONT_SIZE, fill_color: TEXT_COLOR, line_spacing: 1.2)
|
||||||
|
composer.style(:link, fill_color: 'hp-blue-light', underline: true)
|
||||||
|
|
||||||
|
composer.new_page
|
||||||
|
|
||||||
|
composer.column(columns: 1) do |column|
|
||||||
|
column.image(PdfIcons.logo_io, width: 40, height: 40, position: :float)
|
||||||
|
|
||||||
|
column.text('DocuSeal',
|
||||||
|
font_size: 20,
|
||||||
|
font: [FONT_BOLD_NAME, { variant: :bold }],
|
||||||
|
width: 100,
|
||||||
|
padding: [11, 0, 0, 8],
|
||||||
|
position: :float, position_hint: :left)
|
||||||
|
|
||||||
|
column.text('Audit Log',
|
||||||
|
font_size: 16,
|
||||||
|
padding: [15, 0, 0, 0],
|
||||||
|
position: :float, position_hint: :right)
|
||||||
|
end
|
||||||
|
|
||||||
|
composer.column(columns: 1) do |column|
|
||||||
|
column.text("Envelope ID: #{submission.id}", font_size: 12, padding: [20, 0, 10, 0], position: :float)
|
||||||
|
column.formatted_text([
|
||||||
|
{ link: verify_url, text: 'Verify', style: :link }
|
||||||
|
], font_size: 9, padding: [22, 0, 10, 0], position: :float, align: :right)
|
||||||
|
end
|
||||||
|
|
||||||
|
composer.draw_box(divider)
|
||||||
|
|
||||||
|
last_submitter = submission.submitters.where.not(completed_at: nil).order(:completed_at).last
|
||||||
|
|
||||||
|
documents_data = Submitters.select_attachments_for_download(last_submitter).map do |document|
|
||||||
|
original_documents = submission.template.documents.select { |e| e.uuid == document.uuid }.presence
|
||||||
|
original_documents ||= submission.template.documents.select do |e|
|
||||||
|
e.image? && submission.schema.any? do |item|
|
||||||
|
item['attachment_uuid'] == e.uuid
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
[document.filename.to_s,
|
||||||
|
composer.document.layout.formatted_text_box(
|
||||||
|
[
|
||||||
|
{ text: "Original SHA256:\n", font: [FONT_BOLD_NAME, { variant: :bold }] },
|
||||||
|
original_documents.map { |d| d.metadata['sha256'] || d.checksum }.join("\n"),
|
||||||
|
"\n",
|
||||||
|
{ text: "Result SHA256:\n", font: [FONT_BOLD_NAME, { variant: :bold }] },
|
||||||
|
document.metadata['sha256'] || document.checksum,
|
||||||
|
"\n",
|
||||||
|
{ text: 'Generated at: ', font: [FONT_BOLD_NAME, { variant: :bold }] },
|
||||||
|
I18n.l(document.created_at, format: :long, locale: account.locale)
|
||||||
|
], line_spacing: 1.8
|
||||||
|
)]
|
||||||
|
end
|
||||||
|
|
||||||
|
composer.table(documents_data, cell_style: { padding: [0, 0, 25, 0], border: { width: 0 } })
|
||||||
|
|
||||||
|
composer.draw_box(divider)
|
||||||
|
|
||||||
|
submitters_data = submission.template_submitters.map do |item|
|
||||||
|
submitter = submission.submitters.find { |e| e.uuid == item['uuid'] }
|
||||||
|
completed_event =
|
||||||
|
submission.submission_events.find { |e| e.submitter_id == submitter.id && e.complete_form? } ||
|
||||||
|
SubmissionEvent.new
|
||||||
|
|
||||||
|
click_email_event =
|
||||||
|
submission.submission_events.find { |e| e.submitter_id == submitter.id && e.click_email? }
|
||||||
|
is_phone_verified =
|
||||||
|
submission.template_fields.any? { |e| e['type'] == 'phone' && e['submitter_uuid'] == submitter.uuid }
|
||||||
|
|
||||||
|
submitter_field_counters = Hash.new { 0 }
|
||||||
|
|
||||||
|
[
|
||||||
|
[
|
||||||
|
composer.document.layout.formatted_text_box(
|
||||||
|
[
|
||||||
|
submission.template_submitters.size > 1 && { text: "#{item['name']}\n" },
|
||||||
|
submitter.email && { text: "#{submitter.email}\n", font: [FONT_BOLD_NAME, { variant: :bold }] },
|
||||||
|
submitter.name && { text: "#{submitter.name}\n" },
|
||||||
|
submitter.phone && { text: "#{submitter.phone}\n" }
|
||||||
|
].compact_blank, line_spacing: 1.8, padding: [0, 20, 0, 0]
|
||||||
|
),
|
||||||
|
composer.document.layout.formatted_text_box(
|
||||||
|
[
|
||||||
|
submitter.email && {
|
||||||
|
text: "Email verification: #{click_email_event ? 'Verified by DocuSeal' : 'Unverififed'}\n"
|
||||||
|
},
|
||||||
|
submitter.phone && {
|
||||||
|
text: "Phone verification: #{is_phone_verified ? 'Verified by DocuSeal' : 'Unverififed'}\n"
|
||||||
|
},
|
||||||
|
completed_event.data['ip'] && { text: "IP: #{completed_event.data['ip']}\n" },
|
||||||
|
completed_event.data['sid'] && { text: "Session ID: #{completed_event.data['sid']}\n" },
|
||||||
|
completed_event.data['ua'] && { text: "User agent: #{completed_event.data['ua']}\n" },
|
||||||
|
"\n"
|
||||||
|
].compact_blank, line_spacing: 1.8, padding: [10, 20, 0, 0]
|
||||||
|
)
|
||||||
|
],
|
||||||
|
submission.template_fields.filter_map do |field|
|
||||||
|
next if field['submitter_uuid'] != submitter.uuid
|
||||||
|
|
||||||
|
submitter_field_counters[field['type']] += 1
|
||||||
|
|
||||||
|
value = submitter.values[field['uuid']]
|
||||||
|
|
||||||
|
[
|
||||||
|
composer.document.layout.formatted_text_box(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: field['name'].presence ||
|
||||||
|
"#{field['type'].titleize} Field #{submitter_field_counters[field['type']]}\n".upcase,
|
||||||
|
font_size: 6
|
||||||
|
}
|
||||||
|
].compact_blank, line_spacing: 1.8, padding: [0, 0, 5, 0]
|
||||||
|
),
|
||||||
|
if field['type'].in?(%w[image signature])
|
||||||
|
attachment = submitter.attachments.find { |a| a.uuid == value }
|
||||||
|
image = Vips::Image.new_from_buffer(attachment.download, '').autorot
|
||||||
|
|
||||||
|
scale = [300.0 / image.width, 300.0 / image.height].min
|
||||||
|
|
||||||
|
io = StringIO.new(image.resize([scale, 1].min).write_to_buffer('.png'))
|
||||||
|
|
||||||
|
composer.document.layout.image(io, padding: [0, 100, 10, 0])
|
||||||
|
elsif field['type'] == 'file'
|
||||||
|
composer.document.layout.formatted_text_box(
|
||||||
|
Array.wrap(value).map do |uuid|
|
||||||
|
attachment = submitter.attachments.find { |a| a.uuid == uuid }
|
||||||
|
link =
|
||||||
|
Rails.application.routes.url_helpers.rails_blob_url(attachment, **Docuseal.default_url_options)
|
||||||
|
|
||||||
|
{ link:, text: "#{attachment.filename}\n", style: :link }
|
||||||
|
end,
|
||||||
|
padding: [0, 0, 10, 0]
|
||||||
|
)
|
||||||
|
elsif field['type'] == 'checkbox'
|
||||||
|
composer.document.layout.formatted_text_box([{ text: value.to_s.titleize }], padding: [0, 0, 10, 0])
|
||||||
|
else
|
||||||
|
value = I18n.l(Date.parse(value), format: :long, locale: account.locale) if field['type'] == 'date'
|
||||||
|
value = value.join(', ') if value.is_a?(Array)
|
||||||
|
|
||||||
|
composer.document.layout.formatted_text_box([{ text: value.to_s.presence || 'n/a' }],
|
||||||
|
padding: [0, 0, 10, 0])
|
||||||
|
end
|
||||||
|
]
|
||||||
|
end.flatten
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
composer.table(submitters_data, cell_style: { padding: [0, 0, 25, 0], border: { width: 0 } })
|
||||||
|
|
||||||
|
composer.draw_box(divider)
|
||||||
|
|
||||||
|
composer.text('Event Log', font_size: 12, padding: [20, 0, 20, 0])
|
||||||
|
|
||||||
|
events_data = submission.submission_events.sort_by(&:event_timestamp).map do |event|
|
||||||
|
submitter = submission.submitters.find { |e| e.id == event.submitter_id }
|
||||||
|
[
|
||||||
|
I18n.l(event.event_timestamp, format: :long, locale: account.locale),
|
||||||
|
composer.document.layout.formatted_text_box(
|
||||||
|
[
|
||||||
|
{ text: SubmissionEvents::EVENT_NAMES[event.event_type.to_sym],
|
||||||
|
font: [FONT_BOLD_NAME, { variant: :bold }] },
|
||||||
|
event.event_type.include?('send_') ? ' to ' : ' by ',
|
||||||
|
if event.event_type.include?('sms')
|
||||||
|
submitter.phone
|
||||||
|
else
|
||||||
|
(submitter.email || submitter.name || submitter.phone)
|
||||||
|
end
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
composer.table(events_data, cell_style: { padding: [0, 0, 20, 0], border: { width: 0 } })
|
||||||
|
|
||||||
|
io = StringIO.new
|
||||||
|
|
||||||
|
composer.document.trailer.info[:Creator] = INFO_CREATOR
|
||||||
|
|
||||||
|
composer.document.sign(io, reason: SIGN_REASON,
|
||||||
|
certificate: pkcs.certificate,
|
||||||
|
key: pkcs.key,
|
||||||
|
certificate_chain: pkcs.ca_certs || [])
|
||||||
|
|
||||||
|
ActiveStorage::Attachment.create!(
|
||||||
|
blob: ActiveStorage::Blob.create_and_upload!(
|
||||||
|
io: StringIO.new(io.string), filename: "Audit Log - #{submission.template.name}.pdf"
|
||||||
|
),
|
||||||
|
name: 'audit_trail',
|
||||||
|
record: submission
|
||||||
|
)
|
||||||
|
end
|
||||||
|
# rubocop:enable Metrics
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -28,7 +28,8 @@ module Submissions
|
|||||||
|
|
||||||
template = submitter.submission.template
|
template = submitter.submission.template
|
||||||
|
|
||||||
pkcs = Accounts.load_signing_pkcs(submitter.submission.template.account)
|
account = submitter.submission.template.account
|
||||||
|
pkcs = Accounts.load_signing_pkcs(account)
|
||||||
|
|
||||||
pdfs_index = build_pdfs_index(submitter)
|
pdfs_index = build_pdfs_index(submitter)
|
||||||
|
|
||||||
@@ -149,7 +150,7 @@ module Submissions
|
|||||||
height - (area['y'] * height))
|
height - (area['y'] * height))
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
value = I18n.l(Date.parse(value), format: :long) if field['type'] == 'date'
|
value = I18n.l(Date.parse(value), format: :long, locale: account.locale) if field['type'] == 'date'
|
||||||
|
|
||||||
text = HexaPDF::Layout::TextFragment.create(Array.wrap(value).join(', '), font: pdf.fonts.add(FONT_NAME),
|
text = HexaPDF::Layout::TextFragment.create(Array.wrap(value).join(', '), font: pdf.fonts.add(FONT_NAME),
|
||||||
font_size:)
|
font_size:)
|
||||||
@@ -214,6 +215,7 @@ module Submissions
|
|||||||
blob: ActiveStorage::Blob.create_and_upload!(
|
blob: ActiveStorage::Blob.create_and_upload!(
|
||||||
io: StringIO.new(io.string), filename: "#{name}.pdf"
|
io: StringIO.new(io.string), filename: "#{name}.pdf"
|
||||||
),
|
),
|
||||||
|
metadata: { sha256: Base64.urlsafe_encode64(Digest::SHA256.digest(io.string)) },
|
||||||
name: 'documents',
|
name: 'documents',
|
||||||
record: submitter
|
record: submitter
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ module Templates
|
|||||||
|
|
||||||
if blob.content_type == PDF_CONTENT_TYPE && blob.metadata['pdf'].nil?
|
if blob.content_type == PDF_CONTENT_TYPE && blob.metadata['pdf'].nil?
|
||||||
blob.metadata['pdf'] = { 'annotations' => Templates::BuildAnnotations.call(document_data) }
|
blob.metadata['pdf'] = { 'annotations' => Templates::BuildAnnotations.call(document_data) }
|
||||||
|
blob.metadata['sha256'] = Base64.urlsafe_encode64(Digest::SHA256.digest(document_data))
|
||||||
end
|
end
|
||||||
|
|
||||||
blob.save!
|
blob.save!
|
||||||
@@ -37,6 +38,7 @@ module Templates
|
|||||||
|
|
||||||
if file.content_type == PDF_CONTENT_TYPE
|
if file.content_type == PDF_CONTENT_TYPE
|
||||||
metadata = { 'identified' => true, 'analyzed' => true,
|
metadata = { 'identified' => true, 'analyzed' => true,
|
||||||
|
'sha256' => Base64.urlsafe_encode64(Digest::SHA256.digest(data)),
|
||||||
'pdf' => { 'annotations' => Templates::BuildAnnotations.call(data) } }
|
'pdf' => { 'annotations' => Templates::BuildAnnotations.call(data) } }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user