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/`), 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 = _( '

Please sign the attached document for case %(case)s.

' '

Open signing page

' '

This link expires on %(expiry)s.

' ) % { '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: %(doc)s 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 %s%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