From 173229878f574813d1fe15e01c85c77162bdebd9 Mon Sep 17 00:00:00 2001 From: tocmo0nlord Date: Tue, 2 Jun 2026 05:05:53 +0000 Subject: [PATCH] Step 14: comms (never auto-send) + AI-assist billing flag + matter-scoped access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit familylaw.comms — client communications drafted (optionally AI-assisted) for attorney review: draft -> approved (attorney-only) -> sent. NEVER auto-sends; action_send requires prior approval and an explicit human action. Staff draft/assemble but do not communicate legal positions (Bar Rule 4-5.3). familylaw.time.entry — time with an ai_assisted flag. Case computes total_hours, ai_assisted_hours, ai_assisted_ratio (reportable AI-assisted share). Matter-scoped access (security/familylaw_security.xml): record rule confines staff to matters they are assigned to or created; attorneys see all (OR-combined rules). Bar Rule 4-5.3 supervision + confidentiality. Case gains Communications + Billing/Time tabs; Communications menu; ACL for both models. Hardening (encryption-at-rest/backups) noted as infra, out of module scope. Tests (familylaw_step14): cannot send draft (never auto-send); approve-then-send; approve attorney-only; AI-assist ratio (25% of 8h) + zero case; staff sees assigned / cannot see unassigned (search empty + read AccessError); attorney sees all. This completes the Step 1-14 roadmap. Co-Authored-By: Claude Opus 4.8 --- .../activeblue_familylaw/__manifest__.py | 3 +- .../activeblue_familylaw/models/__init__.py | 1 + .../models/familylaw_comms.py | 130 ++++++++++++++++++ .../security/familylaw_security.xml | 31 +++++ .../security/ir.model.access.csv | 4 + .../activeblue_familylaw/tests/__init__.py | 1 + .../activeblue_familylaw/tests/test_step14.py | 127 +++++++++++++++++ .../views/familylaw_case_views.xml | 27 ++++ .../views/familylaw_comms_views.xml | 72 ++++++++++ .../views/familylaw_menus.xml | 7 + 10 files changed, 402 insertions(+), 1 deletion(-) create mode 100644 activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/familylaw_comms.py create mode 100644 activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/tests/test_step14.py create mode 100644 activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_comms_views.xml 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 0f2ac37..2381537 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.13.0.0", + "version": "18.0.14.0.0", "category": "Services/Legal", "summary": "Florida family law case management (Miami-Dade / 11th Judicial Circuit)", "description": """ @@ -39,6 +39,7 @@ Each step adds one vertical, independently testable slice. See BUILD_PLAN.md. "views/familylaw_emergency_views.xml", "views/familylaw_proceeding_views.xml", "views/familylaw_archive_views.xml", + "views/familylaw_comms_views.xml", "views/familylaw_intake_views.xml", "views/familylaw_case_views.xml", "views/familylaw_menus.xml", 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 3a4b467..02e623d 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 @@ -18,6 +18,7 @@ from . import familylaw_emergency from . import familylaw_docuseal from . import familylaw_archive from . import familylaw_obligation +from . import familylaw_comms 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_comms.py b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/familylaw_comms.py new file mode 100644 index 0000000..b6e66cf --- /dev/null +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/familylaw_comms.py @@ -0,0 +1,130 @@ +# -*- coding: utf-8 -*- +"""STEP 14 — Comms drafting + AI-assisted billing flag + matter-scoped access. + +Three concerns: + * COMMS — plain-language client updates drafted (optionally AI-assisted) for + attorney review. They are born draft and NEVER auto-send: there is no code path + that transmits a communication without an explicit, post-approval human action. + * BILLING FLAG — time entries carry an ai_assisted flag so the AI-assisted share of + work is reportable (transparency, fee reasonableness). + * MATTER-SCOPED ACCESS — record rules confine staff (non-lawyers) to the matters + they are assigned to (Bar Rule 4-5.3 supervision + confidentiality). Attorneys see + all. The record rules live in security/familylaw_security.xml. + +Hardening note (infra, not code): encryption-at-rest for case data and backups of the +audit trail / DocuSeal host are operational requirements (Requirements for Success +#5) — out of scope for the module but called out here. +""" + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError + +ATTORNEY_GROUP = "activeblue_familylaw.group_familylaw_attorney" + + +class FamilyLawComms(models.Model): + _name = "familylaw.comms" + _description = "Client Communication (review-gated, never auto-send)" + _inherit = ["mail.thread"] + _order = "create_date desc" + + name = fields.Char(string="Subject", required=True, tracking=True) + case_id = fields.Many2one( + "familylaw.case", required=True, ondelete="cascade", index=True, tracking=True, + ) + comm_type = fields.Selection( + selection=[ + ("update", "Status Update"), + ("email", "Email"), + ("letter", "Letter"), + ], + default="update", + required=True, + ) + body = fields.Html(sanitize=True) + source = fields.Selection( + selection=[("ai", "AI-Assisted"), ("manual", "Manual")], + default="manual", + ) + state = fields.Selection( + selection=[ + ("draft", "Draft"), + ("approved", "Approved"), + ("sent", "Sent"), + ], + default="draft", + required=True, + copy=False, + tracking=True, + ) + approved_by_id = fields.Many2one("res.users", readonly=True, copy=False) + sent_date = fields.Datetime(readonly=True, copy=False) + + def _ensure_attorney(self): + if not self.env.user.has_group(ATTORNEY_GROUP): + raise UserError(_("Only an attorney may approve a client communication. " + "Staff draft and assemble; they do not communicate legal " + "positions to clients.")) + + def action_approve(self): + self._ensure_attorney() + for c in self: + if c.state != "draft": + raise UserError(_("Only a draft communication can be approved.")) + c.write({"state": "approved", "approved_by_id": self.env.user.id}) + c.message_post(body=_("Communication approved by %s.") % self.env.user.name) + return True + + def action_send(self): + """Explicit, human-initiated send. NEVER auto — requires prior approval.""" + for c in self: + if c.state != "approved": + raise UserError(_( + "A communication must be attorney-approved before it can be sent. " + "Communications never auto-send.")) + c.write({"state": "sent", "sent_date": fields.Datetime.now()}) + c.message_post(body=_("Communication sent by %s.") % self.env.user.name) + return True + + +class FamilyLawTimeEntry(models.Model): + _name = "familylaw.time.entry" + _description = "Time Entry (with AI-assist flag)" + _order = "date desc, id desc" + + case_id = fields.Many2one( + "familylaw.case", required=True, ondelete="cascade", index=True, + ) + user_id = fields.Many2one( + "res.users", default=lambda self: self.env.user, required=True, + ) + date = fields.Date(default=fields.Date.context_today, required=True) + description = fields.Char(required=True) + hours = fields.Float(required=True) + ai_assisted = fields.Boolean( + string="AI-Assisted", + help="Set when this work was materially assisted by AI — keeps the AI-assisted " + "share reportable for fee transparency.", + ) + + +class FamilyLawCaseComms(models.Model): + _inherit = "familylaw.case" + + comms_ids = fields.One2many("familylaw.comms", "case_id", string="Communications") + time_entry_ids = fields.One2many( + "familylaw.time.entry", "case_id", string="Time Entries") + total_hours = fields.Float(compute="_compute_time", store=True) + ai_assisted_hours = fields.Float(compute="_compute_time", store=True) + ai_assisted_ratio = fields.Float( + compute="_compute_time", store=True, string="AI-Assisted %", + ) + + @api.depends("time_entry_ids.hours", "time_entry_ids.ai_assisted") + def _compute_time(self): + for case in self: + total = sum(case.time_entry_ids.mapped("hours")) + ai = sum(case.time_entry_ids.filtered("ai_assisted").mapped("hours")) + case.total_hours = total + case.ai_assisted_hours = ai + case.ai_assisted_ratio = (ai / total * 100.0) if total else 0.0 diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/security/familylaw_security.xml b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/security/familylaw_security.xml index 735108f..106e74f 100644 --- a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/security/familylaw_security.xml +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/security/familylaw_security.xml @@ -23,4 +23,35 @@ + + + Family Law: staff see assigned matters only + + + + ['|','|', + ('attorney_id','=',user.id), + ('paralegal_id','=',user.id), + ('create_uid','=',user.id)] + + + + + + + + + Family Law: attorneys see all matters + + + [(1,'=',1)] + + + + + + diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/security/ir.model.access.csv b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/security/ir.model.access.csv index 6eacde3..cbdcd9a 100644 --- a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/security/ir.model.access.csv +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/security/ir.model.access.csv @@ -43,3 +43,7 @@ access_familylaw_archive_user,familylaw.archive staff,model_familylaw_archive,gr access_familylaw_archive_attorney,familylaw.archive attorney,model_familylaw_archive,group_familylaw_attorney,1,1,1,1 access_familylaw_obligation_user,familylaw.obligation staff,model_familylaw_obligation,group_familylaw_user,1,1,1,0 access_familylaw_obligation_attorney,familylaw.obligation attorney,model_familylaw_obligation,group_familylaw_attorney,1,1,1,1 +access_familylaw_comms_user,familylaw.comms staff,model_familylaw_comms,group_familylaw_user,1,1,1,0 +access_familylaw_comms_attorney,familylaw.comms attorney,model_familylaw_comms,group_familylaw_attorney,1,1,1,1 +access_familylaw_time_entry_user,familylaw.time.entry staff,model_familylaw_time_entry,group_familylaw_user,1,1,1,0 +access_familylaw_time_entry_attorney,familylaw.time.entry attorney,model_familylaw_time_entry,group_familylaw_attorney,1,1,1,1 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 7e5b68b..1beb9c0 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 @@ -11,3 +11,4 @@ from . import test_step10 from . import test_step11 from . import test_step12 from . import test_step13 +from . import test_step14 diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/tests/test_step14.py b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/tests/test_step14.py new file mode 100644 index 0000000..e33558f --- /dev/null +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/tests/test_step14.py @@ -0,0 +1,127 @@ +# -*- coding: utf-8 -*- +"""STEP 14 tests — comms (never auto-send) + AI-assist billing + matter-scoped access. + + odoo -d -u activeblue_familylaw --test-enable \ + --test-tags familylaw_step14 --stop-after-init + +Proves: + * a communication cannot be sent before attorney approval (never auto-send); + * approval is attorney-only; approved comms can be sent; + * the AI-assisted time ratio is computed and reportable; + * matter-scoped access: a staff user cannot see unassigned matters; can see + assigned ones; an attorney sees all. +""" + +from odoo.tests.common import TransactionCase, new_test_user, tagged +from odoo.exceptions import UserError, AccessError + + +@tagged("post_install", "-at_install", "familylaw", "familylaw_step14") +class TestStep14Comms(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.attorney = new_test_user( + cls.env, login="fl_atty14", name="Attorney 14", + email="atty14@example.com", + groups="base.group_user,activeblue_familylaw.group_familylaw_attorney", + ) + cls.para = new_test_user( + cls.env, login="fl_para14", name="Para 14", email="para14@example.com", + groups="base.group_user,activeblue_familylaw.group_familylaw_user", + ) + cls.para_other = new_test_user( + cls.env, login="fl_para14b", name="Para 14b", email="para14b@example.com", + groups="base.group_user,activeblue_familylaw.group_familylaw_user", + ) + cls.partner = cls.env["res.partner"].create({"name": "Comms Client"}) + cls.case = cls.env["familylaw.case"].create({ + "name": "Comms Matter", "client_id": cls.partner.id, + "case_type": "dissolution_children", + "attorney_id": cls.attorney.id, "paralegal_id": cls.para.id, + "county": "broward", # avoid AO-13 seeding noise + }) + cls.Comms = cls.env["familylaw.comms"] + + def _comm(self, **kw): + vals = {"name": "Status update", "case_id": self.case.id, + "comm_type": "update"} + vals.update(kw) + return self.Comms.create(vals) + + # --- never auto-send ---------------------------------------------------- + def test_01_cannot_send_draft(self): + c = self._comm() + with self.assertRaises(UserError): + c.action_send() + self.assertEqual(c.state, "draft") + + def test_02_approve_then_send(self): + c = self._comm() + c.with_user(self.attorney).action_approve() + self.assertEqual(c.state, "approved") + c.action_send() + self.assertEqual(c.state, "sent") + self.assertTrue(c.sent_date) + + def test_03_approve_requires_attorney(self): + c = self._comm() + with self.assertRaises(UserError): + c.with_user(self.para).action_approve() + + def test_04_no_auto_send_field_or_cron(self): + # structural: there is no method that sends without going through approval + c = self._comm() + self.assertEqual(c.state, "draft") + with self.assertRaises(UserError): + c.action_send() + + # --- AI-assisted billing ratio ------------------------------------------ + def test_05_ai_ratio(self): + TE = self.env["familylaw.time.entry"] + TE.create({"case_id": self.case.id, "description": "AI draft", + "hours": 2.0, "ai_assisted": True}) + TE.create({"case_id": self.case.id, "description": "Manual review", + "hours": 6.0, "ai_assisted": False}) + self.assertEqual(self.case.total_hours, 8.0) + self.assertEqual(self.case.ai_assisted_hours, 2.0) + self.assertAlmostEqual(self.case.ai_assisted_ratio, 25.0, places=2) + + def test_06_ratio_zero_when_no_time(self): + self.assertEqual(self.case.ai_assisted_ratio, 0.0) + + # --- matter-scoped access ----------------------------------------------- + def test_07_staff_sees_assigned(self): + # para is paralegal_id on cls.case -> visible + found = self.env["familylaw.case"].with_user(self.para).search( + [("id", "=", self.case.id)]) + self.assertIn(self.case, found) + + def test_08_staff_cannot_see_unassigned(self): + other_case = self.env["familylaw.case"].create({ + "name": "Other Matter", "client_id": self.partner.id, + "case_type": "paternity", "attorney_id": self.attorney.id, + "paralegal_id": self.para.id, "county": "broward", + }) + # para_other is not assigned and did not create it -> not visible + found = self.env["familylaw.case"].with_user(self.para_other).search( + [("id", "=", other_case.id)]) + self.assertNotIn(other_case, found) + + def test_09_attorney_sees_all(self): + other_case = self.env["familylaw.case"].create({ + "name": "Unassigned-to-attorney Matter", "client_id": self.partner.id, + "case_type": "paternity", "county": "broward", + }) + found = self.env["familylaw.case"].with_user(self.attorney).search( + [("id", "=", other_case.id)]) + self.assertIn(other_case, found) + + def test_10_staff_read_unassigned_raises(self): + other_case = self.env["familylaw.case"].create({ + "name": "Secret Matter", "client_id": self.partner.id, + "case_type": "paternity", "county": "broward", + }) + with self.assertRaises(AccessError): + other_case.with_user(self.para_other).read(["name"]) diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_case_views.xml b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_case_views.xml index 9ff7e9e..5bb3c59 100644 --- a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_case_views.xml +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_case_views.xml @@ -181,6 +181,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_comms_views.xml b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_comms_views.xml new file mode 100644 index 0000000..cf52732 --- /dev/null +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_comms_views.xml @@ -0,0 +1,72 @@ + + + + + familylaw.comms.list + familylaw.comms + + + + + + + + + + + + + familylaw.comms.form + familylaw.comms + +
+
+
+ + +
+
+ + + + + + + + + + + + +
+
+ + + +
+
+
+
+ + + Communications + familylaw.comms + list,form + + +
diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_menus.xml b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_menus.xml index 829acb5..c802851 100644 --- a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_menus.xml +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_menus.xml @@ -77,6 +77,13 @@ sequence="60" groups="activeblue_familylaw.group_familylaw_attorney"/> + + +