- 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>
256 lines
14 KiB
XML
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 && 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>
|