diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/__init__.py b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/__init__.py index 0650744..f7209b1 100644 --- a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/__init__.py +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/__init__.py @@ -1 +1,2 @@ from . import models +from . import controllers diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/__manifest__.py b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/__manifest__.py index 3807c3f..5439b79 100644 --- a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/__manifest__.py +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/__manifest__.py @@ -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": """ diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/controllers/__init__.py b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/controllers/__init__.py new file mode 100644 index 0000000..12a7e52 --- /dev/null +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/controllers/main.py b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/controllers/main.py new file mode 100644 index 0000000..1cfb6e7 --- /dev/null +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/controllers/main.py @@ -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} diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/__init__.py b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/__init__.py index 55b26cb..df24b22 100644 --- a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/__init__.py +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/__init__.py @@ -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 diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/familylaw_docuseal.py b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/familylaw_docuseal.py new file mode 100644 index 0000000..2f15e91 --- /dev/null +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/familylaw_docuseal.py @@ -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": , "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 diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/tests/__init__.py b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/tests/__init__.py index 648482c..c269f6f 100644 --- a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/tests/__init__.py +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/tests/__init__.py @@ -8,3 +8,4 @@ from . import test_step7 from . import test_step8 from . import test_step9 from . import test_step10 +from . import test_step11 diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/tests/test_step11.py b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/tests/test_step11.py new file mode 100644 index 0000000..143e215 --- /dev/null +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/tests/test_step11.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +"""STEP 11 tests — DocuSeal e-signature (mocked). + + odoo -d -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") diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_document_views.xml b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_document_views.xml index 9980430..8c0ec56 100644 --- a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_document_views.xml +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_document_views.xml @@ -51,6 +51,10 @@ string="Mark Sent" invisible="state != 'approved'" groups="activeblue_familylaw.group_familylaw_attorney"/> +