- 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>
83 lines
3.3 KiB
Python
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)
|