Initial commit

This commit is contained in:
gsinghpal
2026-02-22 01:22:18 -05:00
commit 5200d5baf0
2394 changed files with 386834 additions and 0 deletions

View 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)