Initial commit
This commit is contained in:
322
fusion_authorizer_portal/models/pdf_template.py
Normal file
322
fusion_authorizer_portal/models/pdf_template.py
Normal file
@@ -0,0 +1,322 @@
|
||||
# -*- 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',
|
||||
})
|
||||
|
||||
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')
|
||||
|
||||
# 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)
|
||||
Reference in New Issue
Block a user