Step 11: DocuSeal e-signature (client-facing docs; mocked)

familylaw.docuseal.client (AbstractModel): _create_submission isolates the real
DocuSeal Pro API call (the mock point); send() records submission + flips status.
Pro-tier + internet-facing/isolated-host caveats documented in-module.

familylaw.document (_inherit): signature_status/submission id/signer_email/signed_date.
- action_send_for_signature: attorney-only + requires APPROVED (Gate 1 holds);
  requires signer email. Court filings stay /s/ + Portal, NOT DocuSeal.
- _process_signature_event: webhook handler core — flips sent->signed (or declined);
  unknown submissions are a safe no-op (never raises on stray events).

controllers/main.py: thin POST /familylaw/docuseal/webhook (public, csrf off, shared
-secret check, fail-closed) delegating to the handler. The one internet-facing route.
Send-for-signature button + signature fields on the document form.

Tests (familylaw_step11, DocuSeal mocked): cannot send draft/in-review (Gate 1);
approved sends + records submission; requires signer email; attorney-only; webhook
marks signed/declined; signed alias; unknown-submission no-op.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tocmo0nlord
2026-06-02 04:23:55 +00:00
parent 91d4cec0e0
commit 53285ed5a6
9 changed files with 315 additions and 1 deletions

View File

@@ -1 +1,2 @@
from . import models
from . import controllers

View File

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
{
"name": "Active Blue Family Law",
"version": "18.0.10.0.0",
"version": "18.0.11.0.0",
"category": "Services/Legal",
"summary": "Florida family law case management (Miami-Dade / 11th Judicial Circuit)",
"description": """

View File

@@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
"""STEP 11 — DocuSeal webhook endpoint.
Thin controller: validates a shared secret, then delegates to the document model's
_process_signature_event handler (which is what the unit tests exercise). This route
is the ONE internet-facing endpoint in an otherwise NetBird-internal stack — keep it
hardened (shared secret here; isolate the DocuSeal host itself at the infra layer).
"""
import logging
from odoo import http
from odoo.http import request
_logger = logging.getLogger(__name__)
PARAM_SECRET = "familylaw.docuseal_webhook_secret"
class FamilyLawDocusealController(http.Controller):
@http.route("/familylaw/docuseal/webhook", type="json",
auth="public", csrf=False, methods=["POST"])
def docuseal_webhook(self, **kwargs):
# shared-secret check (header or payload). Fail closed if a secret is set.
secret = request.env["ir.config_parameter"].sudo().get_param(PARAM_SECRET)
if secret:
provided = request.httprequest.headers.get("X-Docuseal-Secret")
if provided != secret:
_logger.warning("DocuSeal webhook rejected: bad secret.")
return {"ok": False, "error": "unauthorized"}
payload = request.get_json_data() if hasattr(request, "get_json_data") else {}
# DocuSeal payloads vary; accept common shapes.
data = payload.get("data", payload) or {}
submission_id = (data.get("submission_id") or data.get("id")
or payload.get("submission_id"))
status = payload.get("event_type") or data.get("status") or payload.get("status")
# normalise common DocuSeal event names
if status in ("submission.completed", "form.completed"):
status = "completed"
request.env["familylaw.document"].sudo()._process_signature_event(
submission_id, status)
return {"ok": True}

View File

@@ -15,6 +15,7 @@ from . import familylaw_research
from . import familylaw_discovery
from . import familylaw_modification
from . import familylaw_emergency
from . import familylaw_docuseal
from . import familylaw_ai_wizard
from . import familylaw_modification_wizard
from . import familylaw_emergency_wizard

View File

@@ -0,0 +1,127 @@
# -*- coding: utf-8 -*-
"""STEP 11 — DocuSeal e-signature for CLIENT-FACING documents.
Court filings use the /s/ convention via the FL E-Filing Portal — NOT DocuSeal.
DocuSeal is only for client-facing docs (engagement letters, consents, etc.).
Two material caveats (design facts, surfaced not buried):
* the API/embedding needed for this integration requires DocuSeal PRO (~$200/yr);
the free tier signs but does not expose the programmatic API.
* the DocuSeal signing host must be INTERNET-FACING, unlike the rest of the
NetBird-internal stack — so it must be isolated and hardened (its Postgres not
exposed, audit trail backed up).
Only an APPROVED document can be sent for signature — the review gate (Gate 1) holds
here too. The real API call lives only in _create_submission (the mock point).
"""
import logging
from odoo import api, fields, models, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
PARAM_URL = "familylaw.docuseal_url"
PARAM_KEY = "familylaw.docuseal_api_key"
PARAM_TEMPLATE = "familylaw.docuseal_template_id"
class FamilyLawDocusealClient(models.AbstractModel):
_name = "familylaw.docuseal.client"
_description = "DocuSeal API client"
def _icp(self):
return self.env["ir.config_parameter"].sudo()
def _create_submission(self, document, signer_email):
"""Create a DocuSeal submission (Pro API). MOCK THIS IN TESTS.
Returns a normalised dict: {"id": <submission id>, "status": "sent"}."""
import requests
base = self._icp().get_param(PARAM_URL)
key = self._icp().get_param(PARAM_KEY)
if not base or not key:
raise UserError(_("DocuSeal is not configured (URL / API key)."))
resp = requests.post(
base.rstrip("/") + "/api/submissions",
headers={"X-Auth-Token": key, "content-type": "application/json"},
json={
"template_id": self._icp().get_param(PARAM_TEMPLATE),
"send_email": True,
"submitters": [{"email": signer_email, "role": "Client"}],
},
timeout=60,
)
resp.raise_for_status()
data = resp.json()
sub_id = (data[0]["submission_id"] if isinstance(data, list) and data
else data.get("id"))
return {"id": str(sub_id), "status": "sent"}
def send(self, document, signer_email):
res = self._create_submission(document, signer_email)
document.write({
"docuseal_submission_id": res["id"],
"signature_status": "sent",
"signer_email": signer_email,
})
document.message_post(
body=_("Sent to %s for e-signature (DocuSeal submission %s).")
% (signer_email, res["id"])
)
return res
class FamilyLawDocumentSignature(models.Model):
_inherit = "familylaw.document"
signature_status = fields.Selection(
selection=[
("none", "Not Sent"),
("sent", "Sent for Signature"),
("signed", "Signed"),
("declined", "Declined"),
],
default="none",
copy=False,
tracking=True,
)
docuseal_submission_id = fields.Char(readonly=True, copy=False)
signer_email = fields.Char(string="Signer Email")
signed_date = fields.Datetime(readonly=True, copy=False)
def action_send_for_signature(self):
"""Send a CLIENT-FACING approved document for e-signature. Attorney-only;
requires approval (Gate 1)."""
self._ensure_attorney()
self._ensure_approved()
for doc in self:
if not doc.signer_email:
raise UserError(
_("Set the signer's email before sending '%s' for signature.")
% doc.name
)
self.env["familylaw.docuseal.client"].send(doc, doc.signer_email)
return True
@api.model
def _process_signature_event(self, submission_id, status):
"""Webhook handler core: flip a document's signature status. Idempotent;
unknown submissions are a no-op (never raises for stray events)."""
if not submission_id:
return False
doc = self.search(
[("docuseal_submission_id", "=", str(submission_id))], limit=1)
if not doc:
_logger.info("DocuSeal event for unknown submission %s — ignored.",
submission_id)
return False
status = (status or "").lower()
if status in ("completed", "signed"):
doc.write({"signature_status": "signed",
"signed_date": fields.Datetime.now()})
doc.message_post(body=_("E-signature COMPLETED (DocuSeal)."))
elif status in ("declined", "cancelled", "canceled", "expired"):
doc.write({"signature_status": "declined"})
doc.message_post(body=_("E-signature declined/cancelled (DocuSeal)."))
return doc

View File

@@ -8,3 +8,4 @@ from . import test_step7
from . import test_step8
from . import test_step9
from . import test_step10
from . import test_step11

View File

@@ -0,0 +1,132 @@
# -*- coding: utf-8 -*-
"""STEP 11 tests — DocuSeal e-signature (mocked).
odoo -d <db> -u activeblue_familylaw --test-enable \
--test-tags familylaw_step11 --stop-after-init
DocuSeal API is mocked (patch _create_submission). Proves:
* only an APPROVED document can be sent for signature (Gate 1 holds);
* sending records the submission id and flips signature_status to 'sent';
* the webhook handler flips a sent doc to 'signed' (and declined);
* a webhook for an unknown submission is a safe no-op.
"""
from unittest.mock import patch
from odoo.tests.common import TransactionCase, new_test_user, tagged
from odoo.exceptions import UserError
DSCLIENT = ("odoo.addons.activeblue_familylaw.models.familylaw_docuseal."
"FamilyLawDocusealClient")
@tagged("post_install", "-at_install", "familylaw", "familylaw_step11")
class TestStep11Docuseal(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.attorney = new_test_user(
cls.env, login="fl_atty11", name="Attorney 11",
email="atty11@example.com",
groups="base.group_user,activeblue_familylaw.group_familylaw_attorney",
)
cls.partner = cls.env["res.partner"].create({"name": "Sign Client"})
cls.case = cls.env["familylaw.case"].create({
"name": "Sign Matter", "client_id": cls.partner.id,
"case_type": "dissolution_children",
})
cls.proc = cls.case.proceeding_ids[0]
cls.Doc = cls.env["familylaw.document"]
def _doc(self, **kw):
vals = {"name": "Engagement Letter", "proceeding_id": self.proc.id,
"document_type": "correspondence", "source": "manual",
"signer_email": "client@example.com"}
vals.update(kw)
return self.Doc.create(vals)
def _approve(self, doc):
doc.action_submit_for_review()
doc.with_user(self.attorney).action_approve()
# --- only approved can send ---------------------------------------------
def test_01_cannot_send_draft(self):
doc = self._doc()
with patch(DSCLIENT + "._create_submission",
return_value={"id": "S1", "status": "sent"}):
with self.assertRaises(UserError):
doc.with_user(self.attorney).action_send_for_signature()
self.assertEqual(doc.signature_status, "none")
def test_02_cannot_send_in_review(self):
doc = self._doc()
doc.action_submit_for_review()
with patch(DSCLIENT + "._create_submission",
return_value={"id": "S1", "status": "sent"}):
with self.assertRaises(UserError):
doc.with_user(self.attorney).action_send_for_signature()
# --- approved sends -----------------------------------------------------
def test_03_approved_sends(self):
doc = self._doc()
self._approve(doc)
with patch(DSCLIENT + "._create_submission",
return_value={"id": "SUB-123", "status": "sent"}):
doc.with_user(self.attorney).action_send_for_signature()
self.assertEqual(doc.signature_status, "sent")
self.assertEqual(doc.docuseal_submission_id, "SUB-123")
def test_04_send_requires_signer_email(self):
doc = self._doc(signer_email=False)
self._approve(doc)
with patch(DSCLIENT + "._create_submission",
return_value={"id": "S1", "status": "sent"}):
with self.assertRaises(UserError):
doc.with_user(self.attorney).action_send_for_signature()
def test_05_send_requires_attorney(self):
doc = self._doc()
self._approve(doc)
para = new_test_user(
self.env, login="fl_para11", name="Para 11", email="p11@example.com",
groups="base.group_user,activeblue_familylaw.group_familylaw_user",
)
with patch(DSCLIENT + "._create_submission",
return_value={"id": "S1", "status": "sent"}):
with self.assertRaises(UserError):
doc.with_user(para).action_send_for_signature()
# --- webhook handler ----------------------------------------------------
def test_06_webhook_marks_signed(self):
doc = self._doc()
self._approve(doc)
with patch(DSCLIENT + "._create_submission",
return_value={"id": "SUB-9", "status": "sent"}):
doc.with_user(self.attorney).action_send_for_signature()
self.Doc._process_signature_event("SUB-9", "completed")
self.assertEqual(doc.signature_status, "signed")
self.assertTrue(doc.signed_date)
def test_07_webhook_declined(self):
doc = self._doc()
self._approve(doc)
with patch(DSCLIENT + "._create_submission",
return_value={"id": "SUB-10", "status": "sent"}):
doc.with_user(self.attorney).action_send_for_signature()
self.Doc._process_signature_event("SUB-10", "declined")
self.assertEqual(doc.signature_status, "declined")
def test_08_webhook_unknown_submission_noop(self):
# must not raise
res = self.Doc._process_signature_event("DOES-NOT-EXIST", "completed")
self.assertFalse(res)
def test_09_webhook_signed_alias(self):
doc = self._doc()
self._approve(doc)
with patch(DSCLIENT + "._create_submission",
return_value={"id": "SUB-11", "status": "sent"}):
doc.with_user(self.attorney).action_send_for_signature()
self.Doc._process_signature_event("SUB-11", "signed")
self.assertEqual(doc.signature_status, "signed")

View File

@@ -51,6 +51,10 @@
string="Mark Sent"
invisible="state != 'approved'"
groups="activeblue_familylaw.group_familylaw_attorney"/>
<button name="action_send_for_signature" type="object"
string="Send for E-Signature"
invisible="state != 'approved' or signature_status != 'none'"
groups="activeblue_familylaw.group_familylaw_attorney"/>
<button name="action_reset_to_draft" type="object"
string="Reset to Draft"
invisible="state in ('ai_draft','filed','sent')"
@@ -85,6 +89,9 @@
<field name="case_id" readonly="1"/>
<field name="approved_by_id" readonly="1"/>
<field name="approved_date" readonly="1"/>
<field name="signature_status"/>
<field name="signer_email"/>
<field name="signed_date" readonly="1"/>
</group>
</group>
<notebook>