diff --git a/activeblue_familylaw/__manifest__.py b/activeblue_familylaw/__manifest__.py index 8a39a79..6758305 100644 --- a/activeblue_familylaw/__manifest__.py +++ b/activeblue_familylaw/__manifest__.py @@ -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', diff --git a/activeblue_familylaw/controllers/__init__.py b/activeblue_familylaw/controllers/__init__.py index 8c3feb6..d9a6d44 100644 --- a/activeblue_familylaw/controllers/__init__.py +++ b/activeblue_familylaw/controllers/__init__.py @@ -1 +1,2 @@ from . import portal +from . import signature diff --git a/activeblue_familylaw/controllers/signature.py b/activeblue_familylaw/controllers/signature.py new file mode 100644 index 0000000..455158d --- /dev/null +++ b/activeblue_familylaw/controllers/signature.py @@ -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/'], + 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//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//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) diff --git a/activeblue_familylaw/models/__init__.py b/activeblue_familylaw/models/__init__.py index 3d91c3c..3efa3d1 100644 --- a/activeblue_familylaw/models/__init__.py +++ b/activeblue_familylaw/models/__init__.py @@ -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 diff --git a/activeblue_familylaw/models/fl_case.py b/activeblue_familylaw/models/fl_case.py index b879b91..71c585d 100644 --- a/activeblue_familylaw/models/fl_case.py +++ b/activeblue_familylaw/models/fl_case.py @@ -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}, + } diff --git a/activeblue_familylaw/models/fl_signature_request.py b/activeblue_familylaw/models/fl_signature_request.py new file mode 100644 index 0000000..6a68d30 --- /dev/null +++ b/activeblue_familylaw/models/fl_signature_request.py @@ -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/`), 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 diff --git a/activeblue_familylaw/security/ir.model.access.csv b/activeblue_familylaw/security/ir.model.access.csv index 0393f7e..5d3479a 100644 --- a/activeblue_familylaw/security/ir.model.access.csv +++ b/activeblue_familylaw/security/ir.model.access.csv @@ -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 diff --git a/activeblue_familylaw/views/fl_case_views.xml b/activeblue_familylaw/views/fl_case_views.xml index 9d5bab1..679357e 100644 --- a/activeblue_familylaw/views/fl_case_views.xml +++ b/activeblue_familylaw/views/fl_case_views.xml @@ -318,6 +318,9 @@