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:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -11,3 +11,4 @@ from . import test_step10
|
||||
from . import test_step11
|
||||
from . import test_step12
|
||||
from . import test_step13
|
||||
from . import test_step14
|
||||
|
||||
@@ -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"])
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user