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>
This commit is contained in:
@@ -1 +1,2 @@
|
||||
from . import portal
|
||||
from . import signature
|
||||
|
||||
82
activeblue_familylaw/controllers/signature.py
Normal file
82
activeblue_familylaw/controllers/signature.py
Normal file
@@ -0,0 +1,82 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user