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:
@@ -1 +1,2 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
|
||||
@@ -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": """
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from . import main
|
||||
@@ -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}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -8,3 +8,4 @@ from . import test_step7
|
||||
from . import test_step8
|
||||
from . import test_step9
|
||||
from . import test_step10
|
||||
from . import test_step11
|
||||
|
||||
@@ -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")
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user