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:
@@ -59,6 +59,7 @@
|
||||
'views/fl_conflict_check_views.xml',
|
||||
'views/fl_timesheet_views.xml',
|
||||
'views/fl_efiling_views.xml',
|
||||
'views/fl_signature_request_views.xml',
|
||||
'views/menu_views.xml',
|
||||
# Phase 4 — QWeb PDF Reports
|
||||
'report/report_financial_affidavit_short.xml',
|
||||
|
||||
@@ -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)
|
||||
@@ -20,3 +20,4 @@ from . import fl_paralegal_agent
|
||||
from . import fl_attorney_agent
|
||||
from . import fl_timesheet
|
||||
from . import fl_efiling
|
||||
from . import fl_signature_request
|
||||
|
||||
@@ -403,6 +403,9 @@ class FlCase(models.Model):
|
||||
efiling_submission_ids = fields.One2many(
|
||||
'fl.efiling.submission', 'case_id', string='e-Filing Submissions'
|
||||
)
|
||||
signature_request_ids = fields.One2many(
|
||||
'fl.signature.request', 'case_id', string='Signature Requests'
|
||||
)
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
# PROJECT / TASK INTEGRATION
|
||||
@@ -1236,3 +1239,14 @@ class FlCase(models.Model):
|
||||
'target': 'new',
|
||||
'context': {'default_case_id': self.id},
|
||||
}
|
||||
|
||||
def action_request_signature(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'New Signature Request',
|
||||
'res_model': 'fl.signature.request',
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
'context': {'default_case_id': self.id},
|
||||
}
|
||||
|
||||
363
activeblue_familylaw/models/fl_signature_request.py
Normal file
363
activeblue_familylaw/models/fl_signature_request.py
Normal file
@@ -0,0 +1,363 @@
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# fl.document.document_type → QWeb report XML id used for both PDF generation
|
||||
# and signature coordinate lookup.
|
||||
DOC_TYPE_TO_REPORT = {
|
||||
'financial_affidavit_short': 'activeblue_familylaw.report_financial_affidavit_short',
|
||||
'financial_affidavit_long': 'activeblue_familylaw.report_financial_affidavit_long',
|
||||
'support_worksheet': 'activeblue_familylaw.report_child_support_worksheet',
|
||||
'motion_to_modify': 'activeblue_familylaw.report_motion_to_modify',
|
||||
'notice_deposition': 'activeblue_familylaw.report_notice_deposition',
|
||||
'motion_to_compel': 'activeblue_familylaw.report_motion_to_compel',
|
||||
'motion_default': 'activeblue_familylaw.report_default_motion',
|
||||
'income_withholding': 'activeblue_familylaw.report_income_withholding',
|
||||
'parenting_plan': 'activeblue_familylaw.report_parenting_plan',
|
||||
'fee_waiver': 'activeblue_familylaw.report_fee_waiver',
|
||||
'notice_ssn': 'activeblue_familylaw.report_notice_ssn',
|
||||
'mandatory_disclosure': 'activeblue_familylaw.report_mandatory_disclosure',
|
||||
}
|
||||
|
||||
# Per-report signature block coordinates. page=-1 means the last page.
|
||||
# Points (1pt = 1/72 in), origin at bottom-left (PyMuPDF uses top-left, conversion
|
||||
# handled in _embed_signature). Tune per form after visual review.
|
||||
_DEFAULT_COORDS = {'page': -1, 'x': 72, 'y': 80, 'w': 240, 'h': 40}
|
||||
_SIGNATURE_COORDS = {
|
||||
'activeblue_familylaw.report_financial_affidavit_short': _DEFAULT_COORDS,
|
||||
'activeblue_familylaw.report_financial_affidavit_long': _DEFAULT_COORDS,
|
||||
'activeblue_familylaw.report_child_support_worksheet': _DEFAULT_COORDS,
|
||||
'activeblue_familylaw.report_motion_to_modify': _DEFAULT_COORDS,
|
||||
'activeblue_familylaw.report_notice_deposition': _DEFAULT_COORDS,
|
||||
'activeblue_familylaw.report_motion_to_compel': _DEFAULT_COORDS,
|
||||
'activeblue_familylaw.report_default_motion': _DEFAULT_COORDS,
|
||||
'activeblue_familylaw.report_income_withholding': _DEFAULT_COORDS,
|
||||
'activeblue_familylaw.report_parenting_plan': _DEFAULT_COORDS,
|
||||
'activeblue_familylaw.report_fee_waiver': _DEFAULT_COORDS,
|
||||
'activeblue_familylaw.report_notice_ssn': _DEFAULT_COORDS,
|
||||
'activeblue_familylaw.report_mandatory_disclosure': _DEFAULT_COORDS,
|
||||
}
|
||||
|
||||
|
||||
class FlSignatureRequest(models.Model):
|
||||
"""
|
||||
Self-hosted e-signature request for a court document.
|
||||
|
||||
Workflow:
|
||||
draft → prepared (PDF rendered) → sent (signer emailed) → signed | declined | expired
|
||||
|
||||
The signer visits a one-time portal URL (`/familylaw/sign/<token>`), draws a
|
||||
signature on an HTML5 canvas pad, and submits. PyMuPDF embeds the PNG into
|
||||
the PDF at the per-report coordinates from _SIGNATURE_COORDS, the signed PDF
|
||||
is attached to the fl.document, and the request is closed.
|
||||
|
||||
Per spec (CLAUDE.md), Odoo Sign / DocuSign / HelloSign are NOT used — all
|
||||
processing is self-hosted for HIPAA + Florida court e-signature compliance.
|
||||
"""
|
||||
_name = 'fl.signature.request'
|
||||
_description = 'Court Document E-Signature Request'
|
||||
_inherit = ['mail.thread']
|
||||
_order = 'create_date desc'
|
||||
_rec_name = 'display_name'
|
||||
|
||||
display_name = fields.Char(compute='_compute_display_name', store=True)
|
||||
case_id = fields.Many2one(
|
||||
'fl.case', string='Case', required=True,
|
||||
ondelete='cascade', index=True, tracking=True
|
||||
)
|
||||
document_id = fields.Many2one(
|
||||
'fl.document', string='Document', required=True,
|
||||
domain="[('case_id', '=', case_id)]", ondelete='restrict', tracking=True
|
||||
)
|
||||
signer_partner_id = fields.Many2one(
|
||||
'res.partner', string='Signer', required=True, tracking=True
|
||||
)
|
||||
signer_email = fields.Char(
|
||||
string='Signer Email', related='signer_partner_id.email', readonly=False
|
||||
)
|
||||
report_ref = fields.Char(
|
||||
string='Report XML ID',
|
||||
help='QWeb report XML id used to render the PDF and look up signature coordinates.'
|
||||
)
|
||||
|
||||
token = fields.Char(
|
||||
string='Access Token', required=True, copy=False, index=True,
|
||||
default=lambda self: secrets.token_urlsafe(32)
|
||||
)
|
||||
expiry_date = fields.Datetime(
|
||||
string='Expires', tracking=True,
|
||||
default=lambda self: fields.Datetime.now() + timedelta(days=14)
|
||||
)
|
||||
state = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('prepared', 'Prepared'),
|
||||
('sent', 'Sent to Signer'),
|
||||
('signed', 'Signed'),
|
||||
('declined', 'Declined'),
|
||||
('expired', 'Expired'),
|
||||
], string='State', default='draft', required=True, tracking=True)
|
||||
|
||||
unsigned_attachment_id = fields.Many2one(
|
||||
'ir.attachment', string='Unsigned PDF', readonly=True
|
||||
)
|
||||
signed_attachment_id = fields.Many2one(
|
||||
'ir.attachment', string='Signed PDF', readonly=True
|
||||
)
|
||||
signature_image = fields.Binary(
|
||||
string='Signature Image (PNG)', attachment=True, readonly=True
|
||||
)
|
||||
signed_at = fields.Datetime(string='Signed At', readonly=True, tracking=True)
|
||||
signed_ip = fields.Char(string='Signer IP', readonly=True)
|
||||
decline_reason = fields.Text(string='Decline Reason')
|
||||
|
||||
_sql_constraints = [
|
||||
('token_unique', 'unique(token)', 'Signature request token must be unique.'),
|
||||
]
|
||||
|
||||
@api.depends('document_id', 'signer_partner_id', 'state')
|
||||
def _compute_display_name(self):
|
||||
for rec in self:
|
||||
doc = rec.document_id.name or _('Document')
|
||||
signer = rec.signer_partner_id.name or _('Signer')
|
||||
rec.display_name = f'{doc} → {signer}'
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Defaulting from the linked document
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@api.onchange('document_id')
|
||||
def _onchange_document_id(self):
|
||||
if self.document_id:
|
||||
self.report_ref = DOC_TYPE_TO_REPORT.get(
|
||||
self.document_id.document_type) or self.report_ref
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if not vals.get('report_ref') and vals.get('document_id'):
|
||||
doc = self.env['fl.document'].browse(vals['document_id'])
|
||||
vals['report_ref'] = DOC_TYPE_TO_REPORT.get(doc.document_type)
|
||||
return super().create(vals_list)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Prepare — render the QWeb PDF for signing
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def action_prepare(self):
|
||||
"""Render the document's QWeb report to PDF and attach it."""
|
||||
self.ensure_one()
|
||||
if not self.report_ref:
|
||||
raise UserError(_(
|
||||
'No report template mapped for document type %s. Set report_ref '
|
||||
'manually or extend DOC_TYPE_TO_REPORT in fl_signature_request.py.'
|
||||
) % (self.document_id.document_type or '—'))
|
||||
|
||||
report = self.env.ref(self.report_ref, raise_if_not_found=False)
|
||||
if not report:
|
||||
raise UserError(_('Report template %s not found.') % self.report_ref)
|
||||
|
||||
pdf_bytes, _mime = self.env['ir.actions.report']._render_qweb_pdf(
|
||||
report, [self.document_id.id])
|
||||
|
||||
attachment = self.env['ir.attachment'].create({
|
||||
'name': f'{self.document_id.name or "document"}_unsigned.pdf',
|
||||
'datas': base64.b64encode(pdf_bytes),
|
||||
'res_model': self._name,
|
||||
'res_id': self.id,
|
||||
'mimetype': 'application/pdf',
|
||||
})
|
||||
self.write({
|
||||
'unsigned_attachment_id': attachment.id,
|
||||
'state': 'prepared',
|
||||
})
|
||||
return True
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Send — email signer with the portal link
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def action_send_to_signer(self):
|
||||
self.ensure_one()
|
||||
if self.state == 'draft':
|
||||
self.action_prepare()
|
||||
if not self.signer_email:
|
||||
raise UserError(_('Signer has no email address.'))
|
||||
|
||||
url = self._portal_url()
|
||||
body = _(
|
||||
'<p>Please sign the attached document for case <b>%(case)s</b>.</p>'
|
||||
'<p><a href="%(url)s">Open signing page</a></p>'
|
||||
'<p>This link expires on %(expiry)s.</p>'
|
||||
) % {
|
||||
'case': self.case_id.name,
|
||||
'url': url,
|
||||
'expiry': fields.Datetime.to_string(self.expiry_date),
|
||||
}
|
||||
self.env['mail.mail'].create({
|
||||
'subject': _('Signature requested: %s') % self.document_id.name,
|
||||
'email_to': self.signer_email,
|
||||
'body_html': body,
|
||||
'attachment_ids': (
|
||||
[(4, self.unsigned_attachment_id.id)] if self.unsigned_attachment_id else False
|
||||
),
|
||||
}).send()
|
||||
|
||||
self.state = 'sent'
|
||||
self.message_post(
|
||||
body=_('📧 Signature link sent to %s') % self.signer_email,
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
return True
|
||||
|
||||
def _portal_url(self):
|
||||
self.ensure_one()
|
||||
base = self.env['ir.config_parameter'].sudo().get_param(
|
||||
'web.base.url', '').rstrip('/')
|
||||
return f'{base}/familylaw/sign/{self.token}'
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Apply signature — called from the portal controller
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def apply_signature(self, png_b64, signer_ip=None):
|
||||
"""
|
||||
Embed the signer's PNG into the unsigned PDF at per-report coordinates,
|
||||
attach the signed PDF to the fl.document, and close the request.
|
||||
Called from the portal controller after the canvas pad submit.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.state not in ('sent', 'prepared'):
|
||||
raise UserError(_('Cannot sign a request in state %s.') % self.state)
|
||||
if not self.unsigned_attachment_id:
|
||||
raise UserError(_('Unsigned PDF missing — re-run Prepare.'))
|
||||
if self.expiry_date and fields.Datetime.now() > self.expiry_date:
|
||||
self.state = 'expired'
|
||||
raise UserError(_('Signing link has expired.'))
|
||||
|
||||
# Decode the canvas-pad PNG (may arrive as "data:image/png;base64,...").
|
||||
if ',' in png_b64:
|
||||
png_b64 = png_b64.split(',', 1)[1]
|
||||
try:
|
||||
png_bytes = base64.b64decode(png_b64)
|
||||
except Exception as exc:
|
||||
raise UserError(_('Invalid signature image: %s') % exc)
|
||||
|
||||
signed_pdf = self._embed_signature(
|
||||
base64.b64decode(self.unsigned_attachment_id.datas), png_bytes)
|
||||
|
||||
signed_att = self.env['ir.attachment'].sudo().create({
|
||||
'name': f'{self.document_id.name or "document"}_signed.pdf',
|
||||
'datas': base64.b64encode(signed_pdf),
|
||||
'res_model': 'fl.document',
|
||||
'res_id': self.document_id.id,
|
||||
'mimetype': 'application/pdf',
|
||||
})
|
||||
|
||||
self.sudo().write({
|
||||
'signed_attachment_id': signed_att.id,
|
||||
'signature_image': base64.b64encode(png_bytes),
|
||||
'signed_at': fields.Datetime.now(),
|
||||
'signed_ip': signer_ip or '',
|
||||
'state': 'signed',
|
||||
})
|
||||
|
||||
# Link the signed PDF to the fl.document and mark it signed.
|
||||
self.document_id.sudo().write({
|
||||
'state': 'signed',
|
||||
'attachment_ids': [(4, signed_att.id)],
|
||||
})
|
||||
|
||||
self.case_id.message_post(
|
||||
body=_('✍️ Document signed: <b>%(doc)s</b> by %(signer)s') % {
|
||||
'doc': self.document_id.name,
|
||||
'signer': self.signer_partner_id.name,
|
||||
},
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
return signed_att
|
||||
|
||||
def _embed_signature(self, pdf_bytes, png_bytes):
|
||||
"""Use PyMuPDF (fitz) to overlay the signature PNG at _SIGNATURE_COORDS."""
|
||||
try:
|
||||
import fitz # PyMuPDF
|
||||
except ImportError:
|
||||
raise UserError(_(
|
||||
'PyMuPDF not installed. Run: pip install PyMuPDF '
|
||||
'(required for embedding signatures into court PDFs).'))
|
||||
|
||||
coords = _SIGNATURE_COORDS.get(self.report_ref, _DEFAULT_COORDS)
|
||||
page_idx = coords['page']
|
||||
x, y, w, h = coords['x'], coords['y'], coords['w'], coords['h']
|
||||
|
||||
doc = fitz.open(stream=pdf_bytes, filetype='pdf')
|
||||
try:
|
||||
if page_idx == -1 or page_idx >= len(doc):
|
||||
page = doc[-1]
|
||||
else:
|
||||
page = doc[page_idx]
|
||||
page_height = page.rect.height
|
||||
# _SIGNATURE_COORDS y is from the bottom; convert to PyMuPDF top-down.
|
||||
top = page_height - (y + h)
|
||||
rect = fitz.Rect(x, top, x + w, top + h)
|
||||
page.insert_image(rect, stream=png_bytes, keep_proportion=True)
|
||||
out = io.BytesIO()
|
||||
doc.save(out, garbage=4, deflate=True)
|
||||
return out.getvalue()
|
||||
finally:
|
||||
doc.close()
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Decline / expire
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def action_decline(self, reason=None):
|
||||
self.ensure_one()
|
||||
if self.state not in ('sent', 'prepared'):
|
||||
raise UserError(_('Cannot decline a request in state %s.') % self.state)
|
||||
self.sudo().write({
|
||||
'state': 'declined',
|
||||
'decline_reason': reason or '',
|
||||
})
|
||||
self.case_id.message_post(
|
||||
body=_('🚫 Signature declined for <b>%s</b>%s') % (
|
||||
self.document_id.name,
|
||||
f': {reason}' if reason else ''),
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
return True
|
||||
|
||||
@api.model
|
||||
def _cron_expire_signature_requests(self):
|
||||
now = fields.Datetime.now()
|
||||
pending = self.search([
|
||||
('state', 'in', ('sent', 'prepared')),
|
||||
('expiry_date', '<', now),
|
||||
])
|
||||
for rec in pending:
|
||||
rec.state = 'expired'
|
||||
return len(pending)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# PDF/A re-validate (for e-filing-bound docs)
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def action_validate_pdfa(self):
|
||||
"""Re-run pikepdf PDF/A check on the signed PDF before e-filing."""
|
||||
self.ensure_one()
|
||||
if not self.signed_attachment_id:
|
||||
raise UserError(_('No signed PDF to validate yet.'))
|
||||
# Delegate to the same check the e-filing model uses.
|
||||
Sub = self.env['fl.efiling.submission']
|
||||
valid, message = Sub._check_pdfa(self.signed_attachment_id)
|
||||
self.message_post(
|
||||
body=('✅ ' if valid else '⚠️ ') + 'PDF/A: ' + message,
|
||||
subtype_xmlid='mail.mt_note',
|
||||
)
|
||||
return valid
|
||||
@@ -69,6 +69,8 @@ access_fl_efiling_submission_admin,fl.efiling.submission admin,model_fl_efiling_
|
||||
access_fl_efiling_submission_paralegal,fl.efiling.submission paralegal,model_fl_efiling_submission,group_paralegal,1,1,1,0
|
||||
access_fl_efiling_wizard_admin,fl.efiling.wizard admin,model_fl_efiling_wizard,group_admin,1,1,1,1
|
||||
access_fl_efiling_wizard_paralegal,fl.efiling.wizard paralegal,model_fl_efiling_wizard,group_paralegal,1,1,1,1
|
||||
access_fl_signature_request_admin,fl.signature.request admin,model_fl_signature_request,group_admin,1,1,1,1
|
||||
access_fl_signature_request_paralegal,fl.signature.request paralegal,model_fl_signature_request,group_paralegal,1,1,1,0
|
||||
access_fl_intake_wizard_admin,fl.intake.wizard admin,model_fl_intake_wizard,group_admin,1,1,1,1
|
||||
access_fl_intake_wizard_paralegal,fl.intake.wizard paralegal,model_fl_intake_wizard,group_paralegal,1,1,1,1
|
||||
access_fl_analysis_wizard_admin,fl.analysis.wizard admin,model_fl_analysis_wizard,group_admin,1,1,1,1
|
||||
|
||||
|
@@ -318,6 +318,9 @@
|
||||
<button name="action_prepare_efiling" string="Prepare e-Filing"
|
||||
type="object" class="btn-primary"
|
||||
groups="activeblue_familylaw.group_admin,activeblue_familylaw.group_paralegal"/>
|
||||
<button name="action_request_signature" string="Request Signature"
|
||||
type="object" class="btn-secondary"
|
||||
groups="activeblue_familylaw.group_admin,activeblue_familylaw.group_paralegal"/>
|
||||
<separator string="Case Documents"/>
|
||||
<field name="document_ids">
|
||||
<list string="Documents">
|
||||
@@ -341,6 +344,19 @@
|
||||
<field name="status"/>
|
||||
</list>
|
||||
</field>
|
||||
<separator string="Signature Requests"/>
|
||||
<field name="signature_request_ids">
|
||||
<list string="Signature Requests"
|
||||
decoration-success="state == 'signed'"
|
||||
decoration-danger="state in ('declined','expired')"
|
||||
decoration-info="state == 'sent'">
|
||||
<field name="document_id"/>
|
||||
<field name="signer_partner_id"/>
|
||||
<field name="expiry_date"/>
|
||||
<field name="signed_at"/>
|
||||
<field name="state"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
|
||||
<!-- TAB 10: Time & Billing -->
|
||||
|
||||
255
activeblue_familylaw/views/fl_signature_request_views.xml
Normal file
255
activeblue_familylaw/views/fl_signature_request_views.xml
Normal file
@@ -0,0 +1,255 @@
|
||||
<?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>
|
||||
@@ -108,6 +108,13 @@
|
||||
action="action_fl_efiling_list"
|
||||
sequence="90"/>
|
||||
|
||||
<menuitem
|
||||
id="menu_fl_signature_requests"
|
||||
name="Signature Requests"
|
||||
parent="menu_fl_cases"
|
||||
action="action_fl_signature_request_list"
|
||||
sequence="100"/>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
SUPPORT CALCULATOR SUB-MENU
|
||||
══════════════════════════════════════════════════════ -->
|
||||
|
||||
Reference in New Issue
Block a user