329 lines
11 KiB
Python
329 lines
11 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Fusion PDF Template Engine
|
|
# Generic system for filling any funding agency's PDF forms
|
|
|
|
import base64
|
|
import logging
|
|
from io import BytesIO
|
|
|
|
from odoo import api, fields, models, _
|
|
from odoo.exceptions import UserError
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class FusionPdfTemplate(models.Model):
|
|
_name = 'fusion.pdf.template'
|
|
_description = 'PDF Form Template'
|
|
_order = 'category, name'
|
|
|
|
name = fields.Char(string='Template Name', required=True)
|
|
category = fields.Selection([
|
|
('adp', 'ADP - Assistive Devices Program'),
|
|
('mod', 'March of Dimes'),
|
|
('odsp', 'ODSP'),
|
|
('hardship', 'Hardship Funding'),
|
|
('other', 'Other'),
|
|
], string='Funding Agency', required=True, default='adp')
|
|
version = fields.Char(string='Form Version', default='1.0')
|
|
state = fields.Selection([
|
|
('draft', 'Draft'),
|
|
('active', 'Active'),
|
|
('archived', 'Archived'),
|
|
], string='Status', default='draft', tracking=True)
|
|
|
|
# The actual PDF template file
|
|
pdf_file = fields.Binary(string='PDF Template', required=True, attachment=True)
|
|
pdf_filename = fields.Char(string='PDF Filename')
|
|
page_count = fields.Integer(
|
|
string='Page Count',
|
|
compute='_compute_page_count',
|
|
store=True,
|
|
)
|
|
|
|
# Page preview images for the visual editor
|
|
preview_ids = fields.One2many(
|
|
'fusion.pdf.template.preview', 'template_id',
|
|
string='Page Previews',
|
|
)
|
|
|
|
# Field positions configured via the visual editor
|
|
field_ids = fields.One2many(
|
|
'fusion.pdf.template.field', 'template_id',
|
|
string='Template Fields',
|
|
)
|
|
field_count = fields.Integer(
|
|
string='Fields',
|
|
compute='_compute_field_count',
|
|
)
|
|
|
|
notes = fields.Text(
|
|
string='Notes',
|
|
help='Usage notes, which assessments/forms use this template',
|
|
)
|
|
|
|
def write(self, vals):
|
|
res = super().write(vals)
|
|
if 'pdf_file' in vals and vals['pdf_file']:
|
|
for rec in self:
|
|
try:
|
|
rec.action_generate_previews()
|
|
except Exception as e:
|
|
_logger.warning("Auto preview generation failed for %s: %s", rec.name, e)
|
|
return res
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
records = super().create(vals_list)
|
|
for rec in records:
|
|
if rec.pdf_file:
|
|
try:
|
|
rec.action_generate_previews()
|
|
except Exception as e:
|
|
_logger.warning("Auto preview generation failed for %s: %s", rec.name, e)
|
|
return records
|
|
|
|
@api.depends('pdf_file')
|
|
def _compute_page_count(self):
|
|
for rec in self:
|
|
if rec.pdf_file:
|
|
try:
|
|
from odoo.tools.pdf import PdfFileReader
|
|
pdf_data = base64.b64decode(rec.pdf_file)
|
|
reader = PdfFileReader(BytesIO(pdf_data))
|
|
rec.page_count = reader.getNumPages()
|
|
except Exception as e:
|
|
_logger.warning("Could not read PDF page count: %s", e)
|
|
rec.page_count = 0
|
|
else:
|
|
rec.page_count = 0
|
|
|
|
def action_generate_previews(self):
|
|
"""Generate PNG preview images from the PDF using poppler (pdftoppm).
|
|
Falls back gracefully if the PDF is protected or poppler is not available.
|
|
"""
|
|
self.ensure_one()
|
|
if not self.pdf_file:
|
|
raise UserError(_('Please upload a PDF file first.'))
|
|
|
|
import subprocess
|
|
import tempfile
|
|
import os
|
|
|
|
pdf_data = base64.b64decode(self.pdf_file)
|
|
|
|
try:
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
pdf_path = os.path.join(tmpdir, 'template.pdf')
|
|
with open(pdf_path, 'wb') as f:
|
|
f.write(pdf_data)
|
|
|
|
# Use pdftoppm to convert each page to PNG
|
|
result = subprocess.run(
|
|
['pdftoppm', '-png', '-r', '200', pdf_path, os.path.join(tmpdir, 'page')],
|
|
capture_output=True, timeout=30,
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
stderr = result.stderr.decode('utf-8', errors='replace')
|
|
_logger.warning("pdftoppm failed: %s", stderr)
|
|
raise UserError(_(
|
|
'Could not generate previews automatically. '
|
|
'The PDF may be protected. Please upload preview images manually '
|
|
'in the Page Previews tab (screenshots of each page).'
|
|
))
|
|
|
|
# Find generated PNG files
|
|
png_files = sorted([
|
|
f for f in os.listdir(tmpdir)
|
|
if f.startswith('page-') and f.endswith('.png')
|
|
])
|
|
|
|
if not png_files:
|
|
raise UserError(_('No pages were generated. Please upload preview images manually.'))
|
|
|
|
# Delete existing previews
|
|
self.preview_ids.unlink()
|
|
|
|
# Create preview records
|
|
for idx, png_file in enumerate(png_files):
|
|
png_path = os.path.join(tmpdir, png_file)
|
|
with open(png_path, 'rb') as f:
|
|
image_data = base64.b64encode(f.read())
|
|
|
|
self.env['fusion.pdf.template.preview'].create({
|
|
'template_id': self.id,
|
|
'page': idx + 1,
|
|
'image': image_data,
|
|
'image_filename': f'page_{idx + 1}.png',
|
|
})
|
|
|
|
_logger.info("Generated %d preview images for template %s", len(png_files), self.name)
|
|
|
|
except subprocess.TimeoutExpired:
|
|
raise UserError(_('PDF conversion timed out. Please upload preview images manually.'))
|
|
except FileNotFoundError:
|
|
raise UserError(_(
|
|
'poppler-utils (pdftoppm) is not installed on the server. '
|
|
'Please upload preview images manually in the Page Previews tab.'
|
|
))
|
|
|
|
@api.depends('field_ids')
|
|
def _compute_field_count(self):
|
|
for rec in self:
|
|
rec.field_count = len(rec.field_ids)
|
|
|
|
def action_activate(self):
|
|
"""Set template to active."""
|
|
self.ensure_one()
|
|
if not self.pdf_file:
|
|
raise UserError(_('Please upload a PDF file before activating.'))
|
|
self.state = 'active'
|
|
|
|
def action_archive(self):
|
|
"""Archive the template."""
|
|
self.ensure_one()
|
|
self.state = 'archived'
|
|
|
|
def action_reset_draft(self):
|
|
"""Reset to draft."""
|
|
self.ensure_one()
|
|
self.state = 'draft'
|
|
|
|
def action_open_field_editor(self):
|
|
"""Open the visual field position editor."""
|
|
self.ensure_one()
|
|
return {
|
|
'type': 'ir.actions.act_url',
|
|
'url': f'/fusion/pdf-editor/{self.id}',
|
|
'target': 'new',
|
|
}
|
|
|
|
def generate_filled_pdf(self, context_data, signatures=None):
|
|
"""Generate a filled PDF using this template and the provided data.
|
|
|
|
Args:
|
|
context_data: flat dict of {field_key: value}
|
|
signatures: dict of {field_key: binary_png} for signature fields
|
|
|
|
Returns:
|
|
bytes of the filled PDF
|
|
"""
|
|
self.ensure_one()
|
|
if not self.pdf_file:
|
|
raise UserError(_('Template has no PDF file.'))
|
|
if self.state != 'active':
|
|
_logger.warning("Generating PDF from non-active template %s", self.name)
|
|
|
|
from ..utils.pdf_filler import PDFTemplateFiller
|
|
|
|
template_bytes = base64.b64decode(self.pdf_file)
|
|
|
|
# Build fields_by_page dict
|
|
fields_by_page = {}
|
|
for field in self.field_ids.filtered(lambda f: f.is_active):
|
|
page = field.page
|
|
if page not in fields_by_page:
|
|
fields_by_page[page] = []
|
|
fields_by_page[page].append({
|
|
'field_name': field.name,
|
|
'field_key': field.field_key or field.name,
|
|
'pos_x': field.pos_x,
|
|
'pos_y': field.pos_y,
|
|
'width': field.width,
|
|
'height': field.height,
|
|
'field_type': field.field_type,
|
|
'font_size': field.font_size,
|
|
'font_name': field.font_name or 'Helvetica',
|
|
'text_align': field.text_align or 'left',
|
|
})
|
|
|
|
return PDFTemplateFiller.fill_template(
|
|
template_bytes, fields_by_page, context_data, signatures
|
|
)
|
|
|
|
|
|
class FusionPdfTemplatePreview(models.Model):
|
|
_name = 'fusion.pdf.template.preview'
|
|
_description = 'PDF Template Page Preview'
|
|
_order = 'page'
|
|
|
|
template_id = fields.Many2one(
|
|
'fusion.pdf.template', string='Template',
|
|
required=True, ondelete='cascade', index=True,
|
|
)
|
|
page = fields.Integer(string='Page Number', required=True, default=1)
|
|
image = fields.Binary(string='Page Image (PNG)', attachment=True)
|
|
image_filename = fields.Char(string='Image Filename')
|
|
|
|
|
|
class FusionPdfTemplateField(models.Model):
|
|
_name = 'fusion.pdf.template.field'
|
|
_description = 'PDF Template Field'
|
|
_order = 'page, sequence'
|
|
|
|
template_id = fields.Many2one(
|
|
'fusion.pdf.template', string='Template',
|
|
required=True, ondelete='cascade', index=True,
|
|
)
|
|
name = fields.Char(
|
|
string='Field Name', required=True,
|
|
help='Internal identifier, e.g. client_last_name',
|
|
)
|
|
label = fields.Char(
|
|
string='Display Label',
|
|
help='Human-readable label shown in the editor, e.g. "Last Name"',
|
|
)
|
|
sequence = fields.Integer(string='Sequence', default=10)
|
|
page = fields.Integer(string='Page', default=1, required=True)
|
|
|
|
# Percentage-based positioning (0.0 to 1.0) -- same as sign.item
|
|
pos_x = fields.Float(
|
|
string='Position X', digits=(4, 3),
|
|
help='Horizontal position as ratio (0.0 = left edge, 1.0 = right edge)',
|
|
)
|
|
pos_y = fields.Float(
|
|
string='Position Y', digits=(4, 3),
|
|
help='Vertical position as ratio (0.0 = top edge, 1.0 = bottom edge)',
|
|
)
|
|
width = fields.Float(
|
|
string='Width', digits=(4, 3), default=0.150,
|
|
help='Width as ratio of page width',
|
|
)
|
|
height = fields.Float(
|
|
string='Height', digits=(4, 3), default=0.015,
|
|
help='Height as ratio of page height',
|
|
)
|
|
|
|
# Rendering settings
|
|
field_type = fields.Selection([
|
|
('text', 'Text'),
|
|
('checkbox', 'Checkbox'),
|
|
('signature', 'Signature Image'),
|
|
('date', 'Date'),
|
|
], string='Field Type', default='text', required=True)
|
|
font_size = fields.Float(string='Font Size', default=10.0)
|
|
font_name = fields.Selection([
|
|
('Helvetica', 'Helvetica'),
|
|
('Courier', 'Courier'),
|
|
('Times-Roman', 'Times Roman'),
|
|
], string='Font', default='Helvetica')
|
|
text_align = fields.Selection([
|
|
('left', 'Left'),
|
|
('center', 'Center'),
|
|
('right', 'Right'),
|
|
], string='Text Alignment', default='left')
|
|
|
|
# Data mapping
|
|
field_key = fields.Char(
|
|
string='Data Key',
|
|
help='Key to look up in the data context dict.\n'
|
|
'Examples: client_last_name, client_health_card, consent_date, signature_page_11\n'
|
|
'The generating code passes a flat dict of all available data.',
|
|
)
|
|
default_value = fields.Char(
|
|
string='Default Value',
|
|
help='Fallback value if field_key returns empty',
|
|
)
|
|
is_active = fields.Boolean(string='Active', default=True)
|