Step 14: comms (never auto-send) + AI-assist billing flag + matter-scoped access

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 <noreply@anthropic.com>
This commit is contained in:
tocmo0nlord
2026-06-02 05:05:53 +00:00
parent 3f00ced566
commit 173229878f
10 changed files with 402 additions and 1 deletions

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View File

@@ -23,4 +23,35 @@
<field name="implied_ids" eval="[(4, ref('group_familylaw_user'))]"/>
</record>
<!-- STEP 14 — matter-scoped access (Bar Rule 4-5.3 supervision).
Staff (non-lawyers) see only matters they are assigned to or created;
attorneys see all. The two rules are OR-combined for attorneys (who are in
both groups), so an attorney is not restricted. -->
<record id="rule_case_staff_assigned" model="ir.rule">
<field name="name">Family Law: staff see assigned matters only</field>
<field name="model_id" ref="model_familylaw_case"/>
<field name="groups" eval="[(4, ref('group_familylaw_user'))]"/>
<field name="domain_force">
['|','|',
('attorney_id','=',user.id),
('paralegal_id','=',user.id),
('create_uid','=',user.id)]
</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<record id="rule_case_attorney_all" model="ir.rule">
<field name="name">Family Law: attorneys see all matters</field>
<field name="model_id" ref="model_familylaw_case"/>
<field name="groups" eval="[(4, ref('group_familylaw_attorney'))]"/>
<field name="domain_force">[(1,'=',1)]</field>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
</odoo>

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
43 access_familylaw_archive_attorney familylaw.archive attorney model_familylaw_archive group_familylaw_attorney 1 1 1 1
44 access_familylaw_obligation_user familylaw.obligation staff model_familylaw_obligation group_familylaw_user 1 1 1 0
45 access_familylaw_obligation_attorney familylaw.obligation attorney model_familylaw_obligation group_familylaw_attorney 1 1 1 1
46 access_familylaw_comms_user familylaw.comms staff model_familylaw_comms group_familylaw_user 1 1 1 0
47 access_familylaw_comms_attorney familylaw.comms attorney model_familylaw_comms group_familylaw_attorney 1 1 1 1
48 access_familylaw_time_entry_user familylaw.time.entry staff model_familylaw_time_entry group_familylaw_user 1 1 1 0
49 access_familylaw_time_entry_attorney familylaw.time.entry attorney model_familylaw_time_entry group_familylaw_attorney 1 1 1 1

View File

@@ -11,3 +11,4 @@ from . import test_step10
from . import test_step11
from . import test_step12
from . import test_step13
from . import test_step14

View File

@@ -0,0 +1,127 @@
# -*- coding: utf-8 -*-
"""STEP 14 tests — comms (never auto-send) + AI-assist billing + matter-scoped access.
odoo -d <db> -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"])

View File

@@ -181,6 +181,33 @@
</list>
</field>
</page>
<page string="Communications" name="comms">
<field name="comms_ids">
<list decoration-success="state == 'sent'"
decoration-muted="state == 'draft'">
<field name="name"/>
<field name="comm_type"/>
<field name="source"/>
<field name="state" widget="badge"/>
</list>
</field>
</page>
<page string="Billing / Time" name="time">
<field name="time_entry_ids">
<list editable="bottom">
<field name="date"/>
<field name="user_id"/>
<field name="description"/>
<field name="hours"/>
<field name="ai_assisted" widget="boolean_toggle"/>
</list>
</field>
<group class="oe_subtotal_footer">
<field name="total_hours"/>
<field name="ai_assisted_hours"/>
<field name="ai_assisted_ratio"/>
</group>
</page>
<page string="Emergency Notes" name="emergency"
invisible="not is_emergency">
<group>

View File

@@ -0,0 +1,72 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_familylaw_comms_list" model="ir.ui.view">
<field name="name">familylaw.comms.list</field>
<field name="model">familylaw.comms</field>
<field name="arch" type="xml">
<list string="Communications"
decoration-success="state == 'sent'"
decoration-info="state == 'approved'"
decoration-muted="state == 'draft'">
<field name="name"/>
<field name="case_id"/>
<field name="comm_type"/>
<field name="source"/>
<field name="state" widget="badge"/>
</list>
</field>
</record>
<record id="view_familylaw_comms_form" model="ir.ui.view">
<field name="name">familylaw.comms.form</field>
<field name="model">familylaw.comms</field>
<field name="arch" type="xml">
<form string="Communication">
<header>
<button name="action_approve" type="object" string="Approve"
class="btn-primary" invisible="state != 'draft'"
groups="activeblue_familylaw.group_familylaw_attorney"/>
<button name="action_send" type="object" string="Send"
invisible="state != 'approved'"/>
<field name="state" widget="statusbar"
statusbar_visible="draft,approved,sent"/>
</header>
<sheet>
<div class="alert alert-info" role="alert" invisible="state == 'sent'">
Communications are attorney-reviewed and NEVER auto-send. Staff
draft and assemble; they do not communicate legal positions.
</div>
<div class="oe_title">
<label for="name"/>
<h1><field name="name"/></h1>
</div>
<group>
<group>
<field name="case_id"/>
<field name="comm_type"/>
<field name="source"/>
</group>
<group>
<field name="approved_by_id" readonly="1"/>
<field name="sent_date" readonly="1"/>
</group>
</group>
<field name="body"/>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="activity_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<record id="action_familylaw_comms" model="ir.actions.act_window">
<field name="name">Communications</field>
<field name="res_model">familylaw.comms</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -77,6 +77,13 @@
sequence="60"
groups="activeblue_familylaw.group_familylaw_attorney"/>
<!-- Communications -->
<menuitem id="menu_familylaw_comms"
name="Communications"
parent="menu_familylaw_root"
action="action_familylaw_comms"
sequence="65"/>
<!-- Archive -->
<menuitem id="menu_familylaw_archive"
name="Archive"