Files
famlaw/activeblue_familylaw/views/fl_signature_request_views.xml
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

256 lines
14 KiB
XML

<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- ══════════════════════════════════════════════════════
BACKEND VIEWS
══════════════════════════════════════════════════════ -->
<record id="view_fl_signature_request_form" model="ir.ui.view">
<field name="name">fl.signature.request.form</field>
<field name="model">fl.signature.request</field>
<field name="arch" type="xml">
<form string="Signature Request">
<header>
<button name="action_prepare" string="Render PDF"
type="object" class="oe_highlight"
invisible="state != 'draft'"/>
<button name="action_send_to_signer" string="Send to Signer"
type="object" class="oe_highlight"
invisible="state not in ['draft', 'prepared']"/>
<button name="action_validate_pdfa" string="Re-validate PDF/A"
type="object"
invisible="state != 'signed'"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,prepared,sent,signed"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="display_name" readonly="1"/></h1>
</div>
<group>
<group>
<field name="case_id"/>
<field name="document_id"/>
<field name="signer_partner_id"/>
<field name="signer_email"/>
</group>
<group>
<field name="report_ref"/>
<field name="expiry_date"/>
<field name="signed_at" readonly="1"
invisible="state != 'signed'"/>
<field name="signed_ip" readonly="1"
invisible="state != 'signed'"/>
</group>
</group>
<group string="Attachments">
<field name="unsigned_attachment_id" readonly="1"/>
<field name="signed_attachment_id" readonly="1"
invisible="state != 'signed'"/>
<field name="signature_image" widget="image" readonly="1"
invisible="state != 'signed'" options="{'size': [240, 80]}"/>
</group>
<group string="Decline" invisible="state != 'declined'">
<field name="decline_reason" readonly="1" nolabel="1"/>
</group>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<record id="view_fl_signature_request_tree" model="ir.ui.view">
<field name="name">fl.signature.request.tree</field>
<field name="model">fl.signature.request</field>
<field name="arch" type="xml">
<list string="Signature Requests"
decoration-success="state == 'signed'"
decoration-danger="state in ('declined','expired')"
decoration-info="state == 'sent'">
<field name="case_id"/>
<field name="document_id"/>
<field name="signer_partner_id"/>
<field name="expiry_date"/>
<field name="signed_at"/>
<field name="state"/>
</list>
</field>
</record>
<record id="view_fl_signature_request_search" model="ir.ui.view">
<field name="name">fl.signature.request.search</field>
<field name="model">fl.signature.request</field>
<field name="arch" type="xml">
<search string="Search Signature Requests">
<field name="case_id"/>
<field name="document_id"/>
<field name="signer_partner_id"/>
<filter string="Pending" name="pending"
domain="[('state', 'in', ['draft','prepared','sent'])]"/>
<filter string="Signed" name="signed"
domain="[('state', '=', 'signed')]"/>
<filter string="Closed (decl/exp)" name="closed"
domain="[('state', 'in', ['declined','expired'])]"/>
<group expand="0" string="Group By">
<filter string="State" name="group_state"
context="{'group_by': 'state'}"/>
<filter string="Case" name="group_case"
context="{'group_by': 'case_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_fl_signature_request_list" model="ir.actions.act_window">
<field name="name">Signature Requests</field>
<field name="res_model">fl.signature.request</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="view_fl_signature_request_search"/>
<field name="context">{'search_default_pending': 1}</field>
</record>
<!-- ══════════════════════════════════════════════════════
PORTAL TEMPLATES — public signing page
══════════════════════════════════════════════════════ -->
<template id="fl_signature_pad" name="Family Law Signature Pad">
<t t-call="website.layout">
<div class="container py-4" style="max-width: 900px;">
<h2>Sign Document</h2>
<p t-if="req">
Case: <b><t t-esc="req.case_id.name"/></b><br/>
Document: <b><t t-esc="req.document_id.name"/></b><br/>
Signer: <b><t t-esc="req.signer_partner_id.name"/></b>
</p>
<div class="alert alert-danger" t-if="error">
<t t-esc="error"/>
</div>
<div class="mb-3" t-if="pdf_url">
<iframe t-att-src="pdf_url" style="width:100%;height:520px;border:1px solid #ccc;"/>
</div>
<h4>Draw your signature</h4>
<p class="text-muted small">
Sign in the box below using your mouse, trackpad, or finger.
By signing you confirm you are the named signer and authorize
the use of this signature on this document.
</p>
<canvas id="fl-sig-pad" width="600" height="160"
style="border:2px dashed #888; touch-action:none; background:#fff; width:100%; max-width:600px;"/>
<div class="mt-2">
<button type="button" class="btn btn-secondary btn-sm"
onclick="flSigClear()">Clear</button>
</div>
<form t-att-action="'/familylaw/sign/' + token + '/submit'"
method="POST" class="mt-3" onsubmit="return flSigSubmit(event)">
<input type="hidden" name="signature" id="fl-sig-input"/>
<button type="submit" class="btn btn-primary">Submit Signature</button>
</form>
<form t-att-action="'/familylaw/sign/' + token + '/decline'"
method="POST" class="mt-4">
<label class="text-muted small">Decline to sign (optional reason):</label>
<textarea name="reason" class="form-control mb-2" rows="2"/>
<button type="submit" class="btn btn-outline-danger btn-sm">Decline</button>
</form>
<script>
(function(){
var canvas = document.getElementById('fl-sig-pad');
var ctx = canvas.getContext('2d');
var drawing = false, last = null, drew = false;
ctx.lineWidth = 2.2; ctx.lineCap = 'round';
ctx.strokeStyle = '#0a1f44';
function pt(e){
var r = canvas.getBoundingClientRect();
var t = (e.touches &amp;&amp; e.touches[0]) || e;
return {x: (t.clientX - r.left) * (canvas.width / r.width),
y: (t.clientY - r.top) * (canvas.height / r.height)};
}
function start(e){ e.preventDefault(); drawing = true; last = pt(e); }
function move(e){
if(!drawing) return; e.preventDefault();
var p = pt(e);
ctx.beginPath(); ctx.moveTo(last.x, last.y); ctx.lineTo(p.x, p.y); ctx.stroke();
last = p; drew = true;
}
function end(){ drawing = false; last = null; }
canvas.addEventListener('mousedown', start);
canvas.addEventListener('mousemove', move);
window.addEventListener('mouseup', end);
canvas.addEventListener('touchstart', start, {passive:false});
canvas.addEventListener('touchmove', move, {passive:false});
canvas.addEventListener('touchend', end);
window.flSigClear = function(){
ctx.clearRect(0, 0, canvas.width, canvas.height); drew = false;
};
window.flSigSubmit = function(ev){
if(!drew){
alert('Please draw your signature before submitting.');
ev.preventDefault(); return false;
}
document.getElementById('fl-sig-input').value = canvas.toDataURL('image/png');
return true;
};
})();
</script>
</div>
</t>
</template>
<template id="fl_signature_done" name="Family Law Signature Done">
<t t-call="website.layout">
<div class="container py-5 text-center" style="max-width: 720px;">
<h2 class="text-success">✅ Signature received</h2>
<p t-if="req">
Thank you. Your signature for <b><t t-esc="req.document_id.name"/></b>
has been recorded.
</p>
</div>
</t>
</template>
<template id="fl_signature_closed" name="Family Law Signature Closed">
<t t-call="website.layout">
<div class="container py-5 text-center" style="max-width: 720px;">
<h2 class="text-muted">This signing link is no longer active.</h2>
<p t-if="req">Current status: <b><t t-esc="req.state"/></b></p>
<p>Contact the attorney's office if you believe this is a mistake.</p>
</div>
</t>
</template>
<template id="fl_signature_invalid" name="Family Law Signature Invalid">
<t t-call="website.layout">
<div class="container py-5 text-center" style="max-width: 720px;">
<h2 class="text-danger">Invalid signing link</h2>
<p>This signature link is invalid or has already been used.</p>
</div>
</t>
</template>
<!-- ══════════════════════════════════════════════════════
CRON — expire pending signature requests
══════════════════════════════════════════════════════ -->
<record id="cron_fl_signature_expire" model="ir.cron">
<field name="name">FL Family Law: Expire Signature Requests</field>
<field name="model_id" ref="model_fl_signature_request"/>
<field name="state">code</field>
<field name="code">model._cron_expire_signature_requests()</field>
<field name="interval_number">1</field>
<field name="interval_type">hours</field>
<field name="active">True</field>
<field name="user_id" ref="base.user_root"/>
</record>
</data>
</odoo>