Files
famlaw/activeblue_familylaw/models/fl_signature_request.py
tocmo0nlord 49358183e7 Add self-hosted e-signature workflow (canvas pad + PyMuPDF embedding)
- fl.signature.request: token-protected request linking an fl.document to a
  signer (res.partner). State machine: draft → prepared → sent → signed |
  declined | expired. Per-report _SIGNATURE_COORDS map plus DOC_TYPE_TO_REPORT
  for resolving the QWeb report template and the signature block rectangle
- action_prepare renders the QWeb PDF and stores it; action_send_to_signer
  emails a one-time link /familylaw/sign/<token> (256-bit token, 14-day expiry,
  hourly cron sweeps stale links)
- apply_signature decodes the canvas-pad PNG, embeds it at the page-relative
  rectangle via PyMuPDF, attaches the signed PDF to the fl.document, marks
  the document signed, and audits the signer IP + timestamp
- Public portal controller (/familylaw/sign/<token>): GET shows the unsigned
  PDF in an iframe + inline HTML5 canvas pad (no external JS, mouse + touch);
  POST submits the PNG; separate decline endpoint. Token+state checks gate
  every transition
- action_validate_pdfa on the signed request reuses the e-filing pikepdf
  check (markers + OutputIntents) so e-filing-bound docs can be re-validated
- Wiring: models/__init__, controllers/__init__, manifest entry, ACL for the
  request, Signature Requests menu under Cases, signature_request_ids on
  fl.case with a Filings-tab list, "Request Signature" header button, and a
  cron for expiry
- Note: Odoo Sign / DocuSign / HelloSign deliberately NOT used per CLAUDE.md
  spec (HIPAA + FL court e-signature compliance)
- Verified: throwaway-DB install passes cleanly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-30 20:43:09 +00:00

364 lines
16 KiB
Python

import base64
import io
import logging
import secrets
from datetime import timedelta
from odoo import _, api, fields, models
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
# fl.document.document_type → QWeb report XML id used for both PDF generation
# and signature coordinate lookup.
DOC_TYPE_TO_REPORT = {
'financial_affidavit_short': 'activeblue_familylaw.report_financial_affidavit_short',
'financial_affidavit_long': 'activeblue_familylaw.report_financial_affidavit_long',
'support_worksheet': 'activeblue_familylaw.report_child_support_worksheet',
'motion_to_modify': 'activeblue_familylaw.report_motion_to_modify',
'notice_deposition': 'activeblue_familylaw.report_notice_deposition',
'motion_to_compel': 'activeblue_familylaw.report_motion_to_compel',
'motion_default': 'activeblue_familylaw.report_default_motion',
'income_withholding': 'activeblue_familylaw.report_income_withholding',
'parenting_plan': 'activeblue_familylaw.report_parenting_plan',
'fee_waiver': 'activeblue_familylaw.report_fee_waiver',
'notice_ssn': 'activeblue_familylaw.report_notice_ssn',
'mandatory_disclosure': 'activeblue_familylaw.report_mandatory_disclosure',
}
# Per-report signature block coordinates. page=-1 means the last page.
# Points (1pt = 1/72 in), origin at bottom-left (PyMuPDF uses top-left, conversion
# handled in _embed_signature). Tune per form after visual review.
_DEFAULT_COORDS = {'page': -1, 'x': 72, 'y': 80, 'w': 240, 'h': 40}
_SIGNATURE_COORDS = {
'activeblue_familylaw.report_financial_affidavit_short': _DEFAULT_COORDS,
'activeblue_familylaw.report_financial_affidavit_long': _DEFAULT_COORDS,
'activeblue_familylaw.report_child_support_worksheet': _DEFAULT_COORDS,
'activeblue_familylaw.report_motion_to_modify': _DEFAULT_COORDS,
'activeblue_familylaw.report_notice_deposition': _DEFAULT_COORDS,
'activeblue_familylaw.report_motion_to_compel': _DEFAULT_COORDS,
'activeblue_familylaw.report_default_motion': _DEFAULT_COORDS,
'activeblue_familylaw.report_income_withholding': _DEFAULT_COORDS,
'activeblue_familylaw.report_parenting_plan': _DEFAULT_COORDS,
'activeblue_familylaw.report_fee_waiver': _DEFAULT_COORDS,
'activeblue_familylaw.report_notice_ssn': _DEFAULT_COORDS,
'activeblue_familylaw.report_mandatory_disclosure': _DEFAULT_COORDS,
}
class FlSignatureRequest(models.Model):
"""
Self-hosted e-signature request for a court document.
Workflow:
draft → prepared (PDF rendered) → sent (signer emailed) → signed | declined | expired
The signer visits a one-time portal URL (`/familylaw/sign/<token>`), draws a
signature on an HTML5 canvas pad, and submits. PyMuPDF embeds the PNG into
the PDF at the per-report coordinates from _SIGNATURE_COORDS, the signed PDF
is attached to the fl.document, and the request is closed.
Per spec (CLAUDE.md), Odoo Sign / DocuSign / HelloSign are NOT used — all
processing is self-hosted for HIPAA + Florida court e-signature compliance.
"""
_name = 'fl.signature.request'
_description = 'Court Document E-Signature Request'
_inherit = ['mail.thread']
_order = 'create_date desc'
_rec_name = 'display_name'
display_name = fields.Char(compute='_compute_display_name', store=True)
case_id = fields.Many2one(
'fl.case', string='Case', required=True,
ondelete='cascade', index=True, tracking=True
)
document_id = fields.Many2one(
'fl.document', string='Document', required=True,
domain="[('case_id', '=', case_id)]", ondelete='restrict', tracking=True
)
signer_partner_id = fields.Many2one(
'res.partner', string='Signer', required=True, tracking=True
)
signer_email = fields.Char(
string='Signer Email', related='signer_partner_id.email', readonly=False
)
report_ref = fields.Char(
string='Report XML ID',
help='QWeb report XML id used to render the PDF and look up signature coordinates.'
)
token = fields.Char(
string='Access Token', required=True, copy=False, index=True,
default=lambda self: secrets.token_urlsafe(32)
)
expiry_date = fields.Datetime(
string='Expires', tracking=True,
default=lambda self: fields.Datetime.now() + timedelta(days=14)
)
state = fields.Selection([
('draft', 'Draft'),
('prepared', 'Prepared'),
('sent', 'Sent to Signer'),
('signed', 'Signed'),
('declined', 'Declined'),
('expired', 'Expired'),
], string='State', default='draft', required=True, tracking=True)
unsigned_attachment_id = fields.Many2one(
'ir.attachment', string='Unsigned PDF', readonly=True
)
signed_attachment_id = fields.Many2one(
'ir.attachment', string='Signed PDF', readonly=True
)
signature_image = fields.Binary(
string='Signature Image (PNG)', attachment=True, readonly=True
)
signed_at = fields.Datetime(string='Signed At', readonly=True, tracking=True)
signed_ip = fields.Char(string='Signer IP', readonly=True)
decline_reason = fields.Text(string='Decline Reason')
_sql_constraints = [
('token_unique', 'unique(token)', 'Signature request token must be unique.'),
]
@api.depends('document_id', 'signer_partner_id', 'state')
def _compute_display_name(self):
for rec in self:
doc = rec.document_id.name or _('Document')
signer = rec.signer_partner_id.name or _('Signer')
rec.display_name = f'{doc}{signer}'
# ──────────────────────────────────────────────────────────────────────
# Defaulting from the linked document
# ──────────────────────────────────────────────────────────────────────
@api.onchange('document_id')
def _onchange_document_id(self):
if self.document_id:
self.report_ref = DOC_TYPE_TO_REPORT.get(
self.document_id.document_type) or self.report_ref
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('report_ref') and vals.get('document_id'):
doc = self.env['fl.document'].browse(vals['document_id'])
vals['report_ref'] = DOC_TYPE_TO_REPORT.get(doc.document_type)
return super().create(vals_list)
# ──────────────────────────────────────────────────────────────────────
# Prepare — render the QWeb PDF for signing
# ──────────────────────────────────────────────────────────────────────
def action_prepare(self):
"""Render the document's QWeb report to PDF and attach it."""
self.ensure_one()
if not self.report_ref:
raise UserError(_(
'No report template mapped for document type %s. Set report_ref '
'manually or extend DOC_TYPE_TO_REPORT in fl_signature_request.py.'
) % (self.document_id.document_type or ''))
report = self.env.ref(self.report_ref, raise_if_not_found=False)
if not report:
raise UserError(_('Report template %s not found.') % self.report_ref)
pdf_bytes, _mime = self.env['ir.actions.report']._render_qweb_pdf(
report, [self.document_id.id])
attachment = self.env['ir.attachment'].create({
'name': f'{self.document_id.name or "document"}_unsigned.pdf',
'datas': base64.b64encode(pdf_bytes),
'res_model': self._name,
'res_id': self.id,
'mimetype': 'application/pdf',
})
self.write({
'unsigned_attachment_id': attachment.id,
'state': 'prepared',
})
return True
# ──────────────────────────────────────────────────────────────────────
# Send — email signer with the portal link
# ──────────────────────────────────────────────────────────────────────
def action_send_to_signer(self):
self.ensure_one()
if self.state == 'draft':
self.action_prepare()
if not self.signer_email:
raise UserError(_('Signer has no email address.'))
url = self._portal_url()
body = _(
'<p>Please sign the attached document for case <b>%(case)s</b>.</p>'
'<p><a href="%(url)s">Open signing page</a></p>'
'<p>This link expires on %(expiry)s.</p>'
) % {
'case': self.case_id.name,
'url': url,
'expiry': fields.Datetime.to_string(self.expiry_date),
}
self.env['mail.mail'].create({
'subject': _('Signature requested: %s') % self.document_id.name,
'email_to': self.signer_email,
'body_html': body,
'attachment_ids': (
[(4, self.unsigned_attachment_id.id)] if self.unsigned_attachment_id else False
),
}).send()
self.state = 'sent'
self.message_post(
body=_('📧 Signature link sent to %s') % self.signer_email,
subtype_xmlid='mail.mt_note',
)
return True
def _portal_url(self):
self.ensure_one()
base = self.env['ir.config_parameter'].sudo().get_param(
'web.base.url', '').rstrip('/')
return f'{base}/familylaw/sign/{self.token}'
# ──────────────────────────────────────────────────────────────────────
# Apply signature — called from the portal controller
# ──────────────────────────────────────────────────────────────────────
def apply_signature(self, png_b64, signer_ip=None):
"""
Embed the signer's PNG into the unsigned PDF at per-report coordinates,
attach the signed PDF to the fl.document, and close the request.
Called from the portal controller after the canvas pad submit.
"""
self.ensure_one()
if self.state not in ('sent', 'prepared'):
raise UserError(_('Cannot sign a request in state %s.') % self.state)
if not self.unsigned_attachment_id:
raise UserError(_('Unsigned PDF missing — re-run Prepare.'))
if self.expiry_date and fields.Datetime.now() > self.expiry_date:
self.state = 'expired'
raise UserError(_('Signing link has expired.'))
# Decode the canvas-pad PNG (may arrive as "data:image/png;base64,...").
if ',' in png_b64:
png_b64 = png_b64.split(',', 1)[1]
try:
png_bytes = base64.b64decode(png_b64)
except Exception as exc:
raise UserError(_('Invalid signature image: %s') % exc)
signed_pdf = self._embed_signature(
base64.b64decode(self.unsigned_attachment_id.datas), png_bytes)
signed_att = self.env['ir.attachment'].sudo().create({
'name': f'{self.document_id.name or "document"}_signed.pdf',
'datas': base64.b64encode(signed_pdf),
'res_model': 'fl.document',
'res_id': self.document_id.id,
'mimetype': 'application/pdf',
})
self.sudo().write({
'signed_attachment_id': signed_att.id,
'signature_image': base64.b64encode(png_bytes),
'signed_at': fields.Datetime.now(),
'signed_ip': signer_ip or '',
'state': 'signed',
})
# Link the signed PDF to the fl.document and mark it signed.
self.document_id.sudo().write({
'state': 'signed',
'attachment_ids': [(4, signed_att.id)],
})
self.case_id.message_post(
body=_('✍️ Document signed: <b>%(doc)s</b> by %(signer)s') % {
'doc': self.document_id.name,
'signer': self.signer_partner_id.name,
},
subtype_xmlid='mail.mt_note',
)
return signed_att
def _embed_signature(self, pdf_bytes, png_bytes):
"""Use PyMuPDF (fitz) to overlay the signature PNG at _SIGNATURE_COORDS."""
try:
import fitz # PyMuPDF
except ImportError:
raise UserError(_(
'PyMuPDF not installed. Run: pip install PyMuPDF '
'(required for embedding signatures into court PDFs).'))
coords = _SIGNATURE_COORDS.get(self.report_ref, _DEFAULT_COORDS)
page_idx = coords['page']
x, y, w, h = coords['x'], coords['y'], coords['w'], coords['h']
doc = fitz.open(stream=pdf_bytes, filetype='pdf')
try:
if page_idx == -1 or page_idx >= len(doc):
page = doc[-1]
else:
page = doc[page_idx]
page_height = page.rect.height
# _SIGNATURE_COORDS y is from the bottom; convert to PyMuPDF top-down.
top = page_height - (y + h)
rect = fitz.Rect(x, top, x + w, top + h)
page.insert_image(rect, stream=png_bytes, keep_proportion=True)
out = io.BytesIO()
doc.save(out, garbage=4, deflate=True)
return out.getvalue()
finally:
doc.close()
# ──────────────────────────────────────────────────────────────────────
# Decline / expire
# ──────────────────────────────────────────────────────────────────────
def action_decline(self, reason=None):
self.ensure_one()
if self.state not in ('sent', 'prepared'):
raise UserError(_('Cannot decline a request in state %s.') % self.state)
self.sudo().write({
'state': 'declined',
'decline_reason': reason or '',
})
self.case_id.message_post(
body=_('🚫 Signature declined for <b>%s</b>%s') % (
self.document_id.name,
f': {reason}' if reason else ''),
subtype_xmlid='mail.mt_note',
)
return True
@api.model
def _cron_expire_signature_requests(self):
now = fields.Datetime.now()
pending = self.search([
('state', 'in', ('sent', 'prepared')),
('expiry_date', '<', now),
])
for rec in pending:
rec.state = 'expired'
return len(pending)
# ──────────────────────────────────────────────────────────────────────
# PDF/A re-validate (for e-filing-bound docs)
# ──────────────────────────────────────────────────────────────────────
def action_validate_pdfa(self):
"""Re-run pikepdf PDF/A check on the signed PDF before e-filing."""
self.ensure_one()
if not self.signed_attachment_id:
raise UserError(_('No signed PDF to validate yet.'))
# Delegate to the same check the e-filing model uses.
Sub = self.env['fl.efiling.submission']
valid, message = Sub._check_pdfa(self.signed_attachment_id)
self.message_post(
body=('' if valid else '⚠️ ') + 'PDF/A: ' + message,
subtype_xmlid='mail.mt_note',
)
return valid