Files
famlaw/activeblue_familylaw/controllers/signature.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

83 lines
3.3 KiB
Python

import logging
from odoo import http
from odoo.exceptions import UserError
from odoo.http import request
_logger = logging.getLogger(__name__)
class FamilyLawSignature(http.Controller):
"""
Public signing portal — one-time per-token URL with an HTML5 canvas pad.
No login required; access is gated by the unguessable token on the request.
"""
@http.route(['/familylaw/sign/<string:token>'],
type='http', auth='public', methods=['GET'], website=True, csrf=False)
def sign_page(self, token, **kw):
req = self._lookup(token)
if not req:
return request.render('activeblue_familylaw.fl_signature_invalid', {})
if req.state == 'signed':
return request.render('activeblue_familylaw.fl_signature_done', {'req': req})
if req.state in ('declined', 'expired'):
return request.render('activeblue_familylaw.fl_signature_closed',
{'req': req})
pdf_url = (f'/web/content/{req.unsigned_attachment_id.id}'
if req.unsigned_attachment_id else '')
return request.render('activeblue_familylaw.fl_signature_pad', {
'req': req,
'pdf_url': pdf_url,
'token': token,
})
@http.route(['/familylaw/sign/<string:token>/submit'],
type='http', auth='public', methods=['POST'], website=True, csrf=False)
def sign_submit(self, token, signature='', **kw):
req = self._lookup(token)
if not req:
return request.render('activeblue_familylaw.fl_signature_invalid', {})
if not signature:
return request.render('activeblue_familylaw.fl_signature_pad', {
'req': req,
'pdf_url': (f'/web/content/{req.unsigned_attachment_id.id}'
if req.unsigned_attachment_id else ''),
'token': token,
'error': 'Please draw your signature before submitting.',
})
try:
req.sudo().apply_signature(
signature,
signer_ip=request.httprequest.headers.get(
'X-Forwarded-For',
request.httprequest.remote_addr or ''),
)
except UserError as exc:
return request.render('activeblue_familylaw.fl_signature_pad', {
'req': req,
'pdf_url': (f'/web/content/{req.unsigned_attachment_id.id}'
if req.unsigned_attachment_id else ''),
'token': token,
'error': str(exc),
})
return request.render('activeblue_familylaw.fl_signature_done', {'req': req})
@http.route(['/familylaw/sign/<string:token>/decline'],
type='http', auth='public', methods=['POST'], website=True, csrf=False)
def sign_decline(self, token, reason='', **kw):
req = self._lookup(token)
if not req:
return request.render('activeblue_familylaw.fl_signature_invalid', {})
try:
req.sudo().action_decline(reason=reason)
except UserError:
pass
return request.render('activeblue_familylaw.fl_signature_closed', {'req': req})
@staticmethod
def _lookup(token):
return request.env['fl.signature.request'].sudo().search(
[('token', '=', token)], limit=1)