Step 3: documents + the attorney review gate (Gate 1)
familylaw.document — every piece of work product, attached to a PROCEEDING: - States: ai_draft -> attorney_review -> approved (+ rejected, filed, sent) - AI-sourced documents born in ai_draft - Gate 1 enforced in code: _ensure_approved() blocks file/send unless approved; _ensure_attorney() restricts approve/reject/file/send to the attorney group - approved_by_id / approved_date stamped on approval; cleared on reject - mail.thread audit on every transition - proceeding.document_ids One2many; case_id derived (stored) from proceeding Views: document list/form/search + menu; inline documents tab on proceeding form. Security + manifest + menu updated. Also folds in a correctness fix for Steps 2-3 view bindings: replaced the invalid `view_id="..."` attribute on x2many fields with canonical inline <list> subviews (case notebook tabs + proceeding documents tab). Avoids load-order/attr issues. Added scripts/validate_module.py: static gate (py compile, xml well-formed, forbidden-construct scan, button->method mapping, manifest+ACL integrity). Tests (familylaw_step3): 16 tests — born-draft, Gate 1 (no file/send unapproved from draft or review), attorney-only approve/reject/file/send, full happy path, reject clears approval + resubmit, audit, proceeding linkage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,21 +1,17 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
"name": "Active Blue Family Law",
|
||||
"version": "18.0.2.0.0",
|
||||
"version": "18.0.3.0.0",
|
||||
"category": "Services/Legal",
|
||||
"summary": "Florida family law case management (Miami-Dade / 11th Judicial Circuit)",
|
||||
"description": """
|
||||
Active Blue Family Law
|
||||
======================
|
||||
Case-management platform for a Florida family-law practice, built in verifiable
|
||||
steps. Step 1 delivers the case spine; Step 2 adds parties, children, issues,
|
||||
the proceeding layer, automated conflict screening, and the intake questionnaire.
|
||||
steps. Step 1: case spine. Step 2: parties/children/issues/proceedings, conflict
|
||||
screening, intake. Step 3: documents + the attorney review gate (Gate 1).
|
||||
|
||||
Each subsequent step adds one vertical, independently testable slice. See
|
||||
BUILD_PLAN.md for the full sequence and the test method.
|
||||
|
||||
Design package: docs 00-11 (architecture, domain, data model, AI agents,
|
||||
signing, citation verification, wire map, training, forms & playbook).
|
||||
Each step adds one vertical, independently testable slice. See BUILD_PLAN.md.
|
||||
""",
|
||||
"author": "Active Blue LLC",
|
||||
"website": "https://activeblue.net",
|
||||
@@ -31,6 +27,7 @@ signing, citation verification, wire map, training, forms & playbook).
|
||||
"views/familylaw_child_views.xml",
|
||||
"views/familylaw_issue_views.xml",
|
||||
"views/familylaw_proceeding_views.xml",
|
||||
"views/familylaw_document_views.xml",
|
||||
"views/familylaw_intake_views.xml",
|
||||
"views/familylaw_case_views.xml",
|
||||
"views/familylaw_menus.xml",
|
||||
|
||||
@@ -5,3 +5,4 @@ from . import familylaw_issue
|
||||
from . import familylaw_proceeding
|
||||
from . import familylaw_conflict
|
||||
from . import familylaw_intake
|
||||
from . import familylaw_document
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""STEP 3 — Documents + THE REVIEW GATE (Gate 1).
|
||||
|
||||
familylaw.document is every piece of work product on a matter. It is attached to a
|
||||
PROCEEDING (not directly to the case — see the locked proceeding decision), and it
|
||||
carries the review-gate state machine:
|
||||
|
||||
ai_draft -> attorney_review -> approved
|
||||
|
||||
Plus terminal/outbound states (rejected, filed, sent). The non-negotiable rule
|
||||
enforced here in code (not policy):
|
||||
|
||||
Nothing can be filed or sent unless it is APPROVED, and only a licensed
|
||||
attorney can approve.
|
||||
|
||||
This is Gate 1. Step 7 adds the second gate (no unverified citation in a filing) on
|
||||
top of the same outbound guard.
|
||||
"""
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
ATTORNEY_GROUP = "activeblue_familylaw.group_familylaw_attorney"
|
||||
|
||||
|
||||
class FamilyLawDocument(models.Model):
|
||||
_name = "familylaw.document"
|
||||
_description = "Case Document (review-gated)"
|
||||
_inherit = ["mail.thread", "mail.activity.mixin"]
|
||||
_order = "create_date desc"
|
||||
|
||||
name = fields.Char(string="Title", required=True, tracking=True)
|
||||
proceeding_id = fields.Many2one(
|
||||
"familylaw.proceeding",
|
||||
string="Proceeding",
|
||||
required=True,
|
||||
ondelete="cascade",
|
||||
index=True,
|
||||
tracking=True,
|
||||
help="Documents attach to a proceeding, not directly to the case.",
|
||||
)
|
||||
case_id = fields.Many2one(
|
||||
"familylaw.case",
|
||||
string="Matter",
|
||||
related="proceeding_id.case_id",
|
||||
store=True,
|
||||
index=True,
|
||||
)
|
||||
document_type = fields.Selection(
|
||||
selection=[
|
||||
("pleading", "Pleading / Motion"),
|
||||
("form", "Florida Supreme Court Form"),
|
||||
("affidavit", "Affidavit"),
|
||||
("order", "Proposed Order"),
|
||||
("correspondence", "Correspondence"),
|
||||
("notice", "Notice"),
|
||||
("other", "Other"),
|
||||
],
|
||||
string="Type",
|
||||
required=True,
|
||||
default="pleading",
|
||||
tracking=True,
|
||||
)
|
||||
source = fields.Selection(
|
||||
selection=[
|
||||
("ai", "AI-Assembled"),
|
||||
("manual", "Manually Drafted"),
|
||||
("uploaded", "Uploaded"),
|
||||
],
|
||||
string="Source",
|
||||
required=True,
|
||||
default="manual",
|
||||
tracking=True,
|
||||
help="AI-assembled documents are born in the ai_draft state and must pass "
|
||||
"attorney review before they can be filed or sent.",
|
||||
)
|
||||
body = fields.Html(string="Body", sanitize=True)
|
||||
privilege = fields.Selection(
|
||||
selection=[
|
||||
("none", "Not Privileged"),
|
||||
("attorney_client", "Attorney-Client Privileged"),
|
||||
("work_product", "Work Product"),
|
||||
],
|
||||
string="Privilege",
|
||||
default="work_product",
|
||||
)
|
||||
|
||||
state = fields.Selection(
|
||||
selection=[
|
||||
("ai_draft", "Draft"),
|
||||
("attorney_review", "In Attorney Review"),
|
||||
("approved", "Approved"),
|
||||
("rejected", "Rejected"),
|
||||
("filed", "Filed"),
|
||||
("sent", "Sent"),
|
||||
],
|
||||
string="Status",
|
||||
default="ai_draft",
|
||||
required=True,
|
||||
copy=False,
|
||||
tracking=True,
|
||||
)
|
||||
approved_by_id = fields.Many2one(
|
||||
"res.users", string="Approved By", readonly=True, copy=False, tracking=True,
|
||||
)
|
||||
approved_date = fields.Datetime(
|
||||
string="Approved On", readonly=True, copy=False, tracking=True,
|
||||
)
|
||||
|
||||
# --- gate helpers -------------------------------------------------------
|
||||
def _ensure_attorney(self):
|
||||
if not self.env.user.has_group(ATTORNEY_GROUP):
|
||||
raise UserError(
|
||||
_("Only a licensed attorney may approve, reject, file, or send a "
|
||||
"document. This is the review gate — it cannot be bypassed.")
|
||||
)
|
||||
|
||||
def _ensure_approved(self):
|
||||
"""Gate 1: refuse any outbound action unless the document is approved."""
|
||||
not_approved = self.filtered(lambda d: d.state not in ("approved",))
|
||||
if not_approved:
|
||||
raise UserError(
|
||||
_("Document '%(name)s' is not approved (status: %(state)s). "
|
||||
"An attorney must approve it before it can be filed or sent. "
|
||||
"Nothing reaches a clerk, court, or client unapproved.",
|
||||
name=not_approved[0].name,
|
||||
state=not_approved[0].state)
|
||||
)
|
||||
|
||||
# --- review workflow ----------------------------------------------------
|
||||
def action_submit_for_review(self):
|
||||
for doc in self:
|
||||
if doc.state not in ("ai_draft", "rejected"):
|
||||
raise UserError(
|
||||
_("Only a draft or rejected document can be submitted for review.")
|
||||
)
|
||||
doc.state = "attorney_review"
|
||||
doc.message_post(body=_("Submitted for attorney review."))
|
||||
return True
|
||||
|
||||
def action_approve(self):
|
||||
self._ensure_attorney()
|
||||
for doc in self:
|
||||
if doc.state != "attorney_review":
|
||||
raise UserError(
|
||||
_("Only a document in attorney review can be approved.")
|
||||
)
|
||||
doc.write({
|
||||
"state": "approved",
|
||||
"approved_by_id": self.env.user.id,
|
||||
"approved_date": fields.Datetime.now(),
|
||||
})
|
||||
doc.message_post(body=_("Approved by %s.") % self.env.user.name)
|
||||
return True
|
||||
|
||||
def action_reject(self):
|
||||
self._ensure_attorney()
|
||||
for doc in self:
|
||||
if doc.state not in ("attorney_review", "approved"):
|
||||
raise UserError(
|
||||
_("Only a document in review or approved can be rejected.")
|
||||
)
|
||||
doc.write({
|
||||
"state": "rejected",
|
||||
"approved_by_id": False,
|
||||
"approved_date": False,
|
||||
})
|
||||
doc.message_post(body=_("Rejected by %s.") % self.env.user.name)
|
||||
return True
|
||||
|
||||
def action_reset_to_draft(self):
|
||||
for doc in self:
|
||||
doc.write({
|
||||
"state": "ai_draft",
|
||||
"approved_by_id": False,
|
||||
"approved_date": False,
|
||||
})
|
||||
doc.message_post(body=_("Reset to draft."))
|
||||
return True
|
||||
|
||||
# --- outbound actions (Gate 1 enforced) ---------------------------------
|
||||
def action_mark_filed(self):
|
||||
self._ensure_attorney()
|
||||
self._ensure_approved()
|
||||
for doc in self:
|
||||
doc.state = "filed"
|
||||
doc.message_post(body=_("Marked as filed by %s.") % self.env.user.name)
|
||||
return True
|
||||
|
||||
def action_mark_sent(self):
|
||||
self._ensure_attorney()
|
||||
self._ensure_approved()
|
||||
for doc in self:
|
||||
doc.state = "sent"
|
||||
doc.message_post(body=_("Marked as sent by %s.") % self.env.user.name)
|
||||
return True
|
||||
@@ -53,6 +53,11 @@ class FamilyLawProceeding(models.Model):
|
||||
date_closed = fields.Date(string="Date Closed")
|
||||
notes = fields.Text()
|
||||
|
||||
# Documents and deadlines attach to the PROCEEDING (locked decision).
|
||||
document_ids = fields.One2many(
|
||||
"familylaw.document", "proceeding_id", string="Documents",
|
||||
)
|
||||
|
||||
def action_close_proceeding(self):
|
||||
for proc in self:
|
||||
proc.state = "closed"
|
||||
|
||||
@@ -12,3 +12,5 @@ access_familylaw_proceeding_attorney,familylaw.proceeding attorney,model_familyl
|
||||
access_familylaw_conflict_hit_user,familylaw.conflict.hit staff,model_familylaw_conflict_hit,group_familylaw_user,1,1,1,0
|
||||
access_familylaw_conflict_hit_attorney,familylaw.conflict.hit attorney,model_familylaw_conflict_hit,group_familylaw_attorney,1,1,1,1
|
||||
access_familylaw_intake_wizard_user,familylaw.intake.wizard user,model_familylaw_intake_wizard,base.group_user,1,1,1,1
|
||||
access_familylaw_document_user,familylaw.document staff,model_familylaw_document,group_familylaw_user,1,1,1,0
|
||||
access_familylaw_document_attorney,familylaw.document attorney,model_familylaw_document,group_familylaw_attorney,1,1,1,1
|
||||
|
||||
|
@@ -1,2 +1,3 @@
|
||||
from . import test_case_lifecycle
|
||||
from . import test_step2
|
||||
from . import test_step3
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""STEP 3 tests — documents + THE REVIEW GATE (Gate 1).
|
||||
|
||||
Run just this step:
|
||||
odoo -d <db> -u activeblue_familylaw --test-enable \
|
||||
--test-tags familylaw_step3 --stop-after-init
|
||||
|
||||
Proves:
|
||||
* an AI-assembled document is born in 'ai_draft';
|
||||
* only an attorney can approve / reject / file / send;
|
||||
* a document cannot be filed or sent unless it is approved (Gate 1);
|
||||
* the review workflow transitions correctly and is audited.
|
||||
"""
|
||||
|
||||
from odoo.tests.common import TransactionCase, new_test_user, tagged
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install", "familylaw", "familylaw_step3")
|
||||
class TestStep3ReviewGate(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.attorney = new_test_user(
|
||||
cls.env, login="fl_atty3", name="Attorney 3",
|
||||
email="atty3@example.com",
|
||||
groups="base.group_user,activeblue_familylaw.group_familylaw_attorney",
|
||||
)
|
||||
cls.paralegal = new_test_user(
|
||||
cls.env, login="fl_para3", name="Paralegal 3",
|
||||
email="para3@example.com",
|
||||
groups="base.group_user,activeblue_familylaw.group_familylaw_user",
|
||||
)
|
||||
cls.partner = cls.env["res.partner"].create({"name": "Doc Client"})
|
||||
cls.case = cls.env["familylaw.case"].create({
|
||||
"name": "Doc Matter",
|
||||
"client_id": cls.partner.id,
|
||||
"case_type": "support_modification",
|
||||
})
|
||||
cls.proceeding = cls.case.proceeding_ids[0]
|
||||
cls.Doc = cls.env["familylaw.document"]
|
||||
|
||||
def _make_doc(self, **kw):
|
||||
vals = {
|
||||
"name": "Test Motion",
|
||||
"proceeding_id": self.proceeding.id,
|
||||
"document_type": "pleading",
|
||||
"source": "ai",
|
||||
}
|
||||
vals.update(kw)
|
||||
return self.Doc.create(vals)
|
||||
|
||||
# --- born as draft ------------------------------------------------------
|
||||
def test_01_ai_document_born_draft(self):
|
||||
doc = self._make_doc(source="ai")
|
||||
self.assertEqual(doc.state, "ai_draft")
|
||||
|
||||
def test_02_case_id_derived_from_proceeding(self):
|
||||
doc = self._make_doc()
|
||||
self.assertEqual(doc.case_id, self.case)
|
||||
|
||||
# --- Gate 1: cannot file/send unapproved --------------------------------
|
||||
def test_03_cannot_file_draft(self):
|
||||
doc = self._make_doc()
|
||||
with self.assertRaises(UserError):
|
||||
doc.with_user(self.attorney).action_mark_filed()
|
||||
self.assertNotEqual(doc.state, "filed")
|
||||
|
||||
def test_04_cannot_send_draft(self):
|
||||
doc = self._make_doc()
|
||||
with self.assertRaises(UserError):
|
||||
doc.with_user(self.attorney).action_mark_sent()
|
||||
self.assertNotEqual(doc.state, "sent")
|
||||
|
||||
def test_05_cannot_file_in_review(self):
|
||||
doc = self._make_doc()
|
||||
doc.action_submit_for_review()
|
||||
with self.assertRaises(UserError):
|
||||
doc.with_user(self.attorney).action_mark_filed()
|
||||
|
||||
# --- attorney-only approval ---------------------------------------------
|
||||
def test_06_approve_requires_attorney(self):
|
||||
doc = self._make_doc()
|
||||
doc.action_submit_for_review()
|
||||
with self.assertRaises(UserError):
|
||||
doc.with_user(self.paralegal).action_approve()
|
||||
self.assertEqual(doc.state, "attorney_review")
|
||||
|
||||
def test_07_attorney_approves(self):
|
||||
doc = self._make_doc()
|
||||
doc.action_submit_for_review()
|
||||
doc.with_user(self.attorney).action_approve()
|
||||
self.assertEqual(doc.state, "approved")
|
||||
self.assertEqual(doc.approved_by_id, self.attorney)
|
||||
self.assertTrue(doc.approved_date)
|
||||
|
||||
def test_08_cannot_approve_draft_directly(self):
|
||||
doc = self._make_doc()
|
||||
with self.assertRaises(UserError):
|
||||
doc.with_user(self.attorney).action_approve()
|
||||
|
||||
# --- full happy path ----------------------------------------------------
|
||||
def test_09_full_path_to_filed(self):
|
||||
doc = self._make_doc()
|
||||
doc.action_submit_for_review()
|
||||
doc.with_user(self.attorney).action_approve()
|
||||
doc.with_user(self.attorney).action_mark_filed()
|
||||
self.assertEqual(doc.state, "filed")
|
||||
|
||||
def test_10_approved_then_send(self):
|
||||
doc = self._make_doc()
|
||||
doc.action_submit_for_review()
|
||||
doc.with_user(self.attorney).action_approve()
|
||||
doc.with_user(self.attorney).action_mark_sent()
|
||||
self.assertEqual(doc.state, "sent")
|
||||
|
||||
# --- reject / reset -----------------------------------------------------
|
||||
def test_11_reject_requires_attorney(self):
|
||||
doc = self._make_doc()
|
||||
doc.action_submit_for_review()
|
||||
with self.assertRaises(UserError):
|
||||
doc.with_user(self.paralegal).action_reject()
|
||||
|
||||
def test_12_reject_clears_approval(self):
|
||||
doc = self._make_doc()
|
||||
doc.action_submit_for_review()
|
||||
doc.with_user(self.attorney).action_approve()
|
||||
doc.with_user(self.attorney).action_reject()
|
||||
self.assertEqual(doc.state, "rejected")
|
||||
self.assertFalse(doc.approved_by_id)
|
||||
self.assertFalse(doc.approved_date)
|
||||
|
||||
def test_13_rejected_can_resubmit(self):
|
||||
doc = self._make_doc()
|
||||
doc.action_submit_for_review()
|
||||
doc.with_user(self.attorney).action_reject()
|
||||
doc.action_submit_for_review()
|
||||
self.assertEqual(doc.state, "attorney_review")
|
||||
|
||||
# --- file/send are attorney-only even if approved -----------------------
|
||||
def test_14_file_requires_attorney(self):
|
||||
doc = self._make_doc()
|
||||
doc.action_submit_for_review()
|
||||
doc.with_user(self.attorney).action_approve()
|
||||
with self.assertRaises(UserError):
|
||||
doc.with_user(self.paralegal).action_mark_filed()
|
||||
|
||||
# --- audit --------------------------------------------------------------
|
||||
def test_15_workflow_is_audited(self):
|
||||
doc = self._make_doc()
|
||||
before = len(doc.message_ids)
|
||||
doc.action_submit_for_review()
|
||||
doc.with_user(self.attorney).action_approve()
|
||||
self.assertGreater(len(doc.message_ids), before)
|
||||
|
||||
# --- proceeding linkage -------------------------------------------------
|
||||
def test_16_document_listed_on_proceeding(self):
|
||||
doc = self._make_doc()
|
||||
self.assertIn(doc, self.proceeding.document_ids)
|
||||
@@ -102,20 +102,46 @@
|
||||
|
||||
<notebook>
|
||||
<page string="Parties" name="parties">
|
||||
<field name="party_ids"
|
||||
view_id="view_familylaw_party_inline_list"/>
|
||||
<field name="party_ids">
|
||||
<list editable="bottom">
|
||||
<field name="name"/>
|
||||
<field name="role" widget="badge"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="notes"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Children" name="children">
|
||||
<field name="child_ids"
|
||||
view_id="view_familylaw_child_inline_list"/>
|
||||
<field name="child_ids">
|
||||
<list editable="bottom">
|
||||
<field name="name"/>
|
||||
<field name="date_of_birth"/>
|
||||
<field name="age" readonly="1"/>
|
||||
<field name="notes"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Issues" name="issues">
|
||||
<field name="issue_ids"
|
||||
view_id="view_familylaw_issue_inline_list"/>
|
||||
<field name="issue_ids">
|
||||
<list editable="bottom">
|
||||
<field name="issue_type"/>
|
||||
<field name="description"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Proceedings" name="proceedings">
|
||||
<field name="proceeding_ids"
|
||||
view_id="view_familylaw_proceeding_inline_list"/>
|
||||
<field name="proceeding_ids">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="proceeding_type"/>
|
||||
<field name="proceeding_number"/>
|
||||
<field name="state" widget="badge"
|
||||
decoration-success="state == 'closed'"
|
||||
decoration-info="state == 'open'"/>
|
||||
<field name="date_opened"/>
|
||||
<field name="date_closed" readonly="1"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Conflict Hits" name="conflict_hits"
|
||||
invisible="conflict_hit_count == 0">
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_familylaw_document_list" model="ir.ui.view">
|
||||
<field name="name">familylaw.document.list</field>
|
||||
<field name="model">familylaw.document</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Documents">
|
||||
<field name="name"/>
|
||||
<field name="case_id"/>
|
||||
<field name="proceeding_id"/>
|
||||
<field name="document_type"/>
|
||||
<field name="source"/>
|
||||
<field name="state" widget="badge"
|
||||
decoration-success="state in ('approved','filed','sent')"
|
||||
decoration-info="state == 'attorney_review'"
|
||||
decoration-danger="state == 'rejected'"
|
||||
decoration-muted="state == 'ai_draft'"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_familylaw_document_form" model="ir.ui.view">
|
||||
<field name="name">familylaw.document.form</field>
|
||||
<field name="model">familylaw.document</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Document">
|
||||
<header>
|
||||
<button name="action_submit_for_review" type="object"
|
||||
string="Submit for Review" class="btn-primary"
|
||||
invisible="state not in ('ai_draft','rejected')"/>
|
||||
<button name="action_approve" type="object"
|
||||
string="Approve" class="btn-primary"
|
||||
invisible="state != 'attorney_review'"
|
||||
groups="activeblue_familylaw.group_familylaw_attorney"/>
|
||||
<button name="action_reject" type="object"
|
||||
string="Reject"
|
||||
invisible="state not in ('attorney_review','approved')"
|
||||
groups="activeblue_familylaw.group_familylaw_attorney"/>
|
||||
<button name="action_mark_filed" type="object"
|
||||
string="Mark Filed"
|
||||
invisible="state != 'approved'"
|
||||
groups="activeblue_familylaw.group_familylaw_attorney"/>
|
||||
<button name="action_mark_sent" type="object"
|
||||
string="Mark Sent"
|
||||
invisible="state != 'approved'"
|
||||
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')"
|
||||
groups="activeblue_familylaw.group_familylaw_attorney"/>
|
||||
<field name="state" widget="statusbar"
|
||||
statusbar_visible="ai_draft,attorney_review,approved,filed"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="alert alert-warning" role="alert"
|
||||
invisible="state in ('approved','filed','sent')">
|
||||
This document is not yet approved. It cannot be filed or sent
|
||||
until a licensed attorney approves it (the review gate).
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<label for="name"/>
|
||||
<h1><field name="name" placeholder="e.g. Motion to Modify Child Support"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Classification">
|
||||
<field name="document_type"/>
|
||||
<field name="source"/>
|
||||
<field name="privilege"/>
|
||||
</group>
|
||||
<group string="Matter">
|
||||
<field name="proceeding_id"/>
|
||||
<field name="case_id" readonly="1"/>
|
||||
<field name="approved_by_id" readonly="1"/>
|
||||
<field name="approved_date" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Body" name="body">
|
||||
<field name="body"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids"/>
|
||||
<field name="activity_ids"/>
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_familylaw_document_search" model="ir.ui.view">
|
||||
<field name="name">familylaw.document.search</field>
|
||||
<field name="model">familylaw.document</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Documents">
|
||||
<field name="name"/>
|
||||
<field name="case_id"/>
|
||||
<field name="proceeding_id"/>
|
||||
<filter name="needs_review" string="Awaiting Review"
|
||||
domain="[('state','=','attorney_review')]"/>
|
||||
<filter name="drafts" string="Drafts"
|
||||
domain="[('state','=','ai_draft')]"/>
|
||||
<filter name="approved" string="Approved"
|
||||
domain="[('state','=','approved')]"/>
|
||||
<separator/>
|
||||
<filter name="ai_assembled" string="AI-Assembled"
|
||||
domain="[('source','=','ai')]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter name="group_state" string="Status"
|
||||
context="{'group_by':'state'}"/>
|
||||
<filter name="group_type" string="Type"
|
||||
context="{'group_by':'document_type'}"/>
|
||||
<filter name="group_case" string="Matter"
|
||||
context="{'group_by':'case_id'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_familylaw_document" model="ir.actions.act_window">
|
||||
<field name="name">Documents</field>
|
||||
<field name="res_model">familylaw.document</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_familylaw_document_search"/>
|
||||
</record>
|
||||
|
||||
<!-- Inline list for the case/proceeding notebook -->
|
||||
<record id="view_familylaw_document_inline_list" model="ir.ui.view">
|
||||
<field name="name">familylaw.document.inline.list</field>
|
||||
<field name="model">familylaw.document</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Documents">
|
||||
<field name="name"/>
|
||||
<field name="document_type"/>
|
||||
<field name="source"/>
|
||||
<field name="state" widget="badge"
|
||||
decoration-success="state in ('approved','filed','sent')"
|
||||
decoration-info="state == 'attorney_review'"
|
||||
decoration-danger="state == 'rejected'"
|
||||
decoration-muted="state == 'ai_draft'"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -20,6 +20,13 @@
|
||||
action="action_familylaw_case"
|
||||
sequence="10"/>
|
||||
|
||||
<!-- Documents -->
|
||||
<menuitem id="menu_familylaw_documents"
|
||||
name="Documents"
|
||||
parent="menu_familylaw_root"
|
||||
action="action_familylaw_document"
|
||||
sequence="20"/>
|
||||
|
||||
<!-- Configuration placeholder (populated in later steps) -->
|
||||
<menuitem id="menu_familylaw_config"
|
||||
name="Configuration"
|
||||
|
||||
@@ -49,6 +49,23 @@
|
||||
<group string="Notes">
|
||||
<field name="notes" nolabel="1"/>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Documents" name="documents">
|
||||
<field name="document_ids"
|
||||
context="{'default_proceeding_id': id}">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="document_type"/>
|
||||
<field name="source"/>
|
||||
<field name="state" widget="badge"
|
||||
decoration-success="state in ('approved','filed','sent')"
|
||||
decoration-info="state == 'attorney_review'"
|
||||
decoration-danger="state == 'rejected'"
|
||||
decoration-muted="state == 'ai_draft'"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids"/>
|
||||
|
||||
113
scripts/validate_module.py
Normal file
113
scripts/validate_module.py
Normal file
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Static validation for the activeblue_familylaw Odoo 18 module.
|
||||
|
||||
Not a substitute for running the Odoo test suite — it catches the classes of error
|
||||
that are cheap to find without a running Odoo/DB:
|
||||
|
||||
* Python files compile
|
||||
* XML files are well-formed
|
||||
* no Odoo-18-forbidden constructs in views (attrs=, states=, <tree>)
|
||||
* every type="object" button name maps to a real method in the model layer
|
||||
* every file listed in __manifest__["data"] exists
|
||||
* ir.model.access.csv references models that are defined
|
||||
|
||||
Run: python3 scripts/validate_module.py
|
||||
"""
|
||||
import ast
|
||||
import csv
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
ROOT = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"activeblue_familylaw_handoff",
|
||||
"activeblue_familylaw_build",
|
||||
"activeblue_familylaw",
|
||||
)
|
||||
|
||||
errors = []
|
||||
ok_count = 0
|
||||
|
||||
|
||||
def ok(_msg):
|
||||
global ok_count
|
||||
ok_count += 1
|
||||
|
||||
|
||||
def walk(ext):
|
||||
for dirpath, _dirs, files in os.walk(ROOT):
|
||||
for f in files:
|
||||
if f.endswith(ext):
|
||||
yield os.path.join(dirpath, f)
|
||||
|
||||
|
||||
# 1. Python compiles + collect method names + model names
|
||||
methods = set()
|
||||
model_names = set()
|
||||
model_tech_names = set() # familylaw.document -> model_familylaw_document
|
||||
for f in walk(".py"):
|
||||
src = open(f).read()
|
||||
try:
|
||||
tree = ast.parse(src)
|
||||
except SyntaxError as e:
|
||||
errors.append(f"PYTHON SYNTAX {f}: {e}")
|
||||
continue
|
||||
ok(f)
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
methods.add(node.name)
|
||||
for m in re.finditer(r'_name\s*=\s*["\']([a-z0-9_.]+)["\']', src):
|
||||
model_names.add(m.group(1))
|
||||
model_tech_names.add("model_" + m.group(1).replace(".", "_"))
|
||||
|
||||
# 2. XML well-formed + forbidden constructs + collect buttons
|
||||
buttons = []
|
||||
for f in walk(".xml"):
|
||||
raw = open(f).read()
|
||||
try:
|
||||
root = ET.fromstring(raw)
|
||||
except ET.ParseError as e:
|
||||
errors.append(f"XML PARSE {f}: {e}")
|
||||
continue
|
||||
ok(f)
|
||||
# strip XML comments before scanning for forbidden tokens
|
||||
no_comments = re.sub(r"<!--.*?-->", "", raw, flags=re.DOTALL)
|
||||
for bad in ("attrs=", "states=", "<tree"):
|
||||
if bad in no_comments:
|
||||
errors.append(f"FORBIDDEN '{bad}' in {f}")
|
||||
for btn in root.iter("button"):
|
||||
if btn.get("type") == "object" and btn.get("name"):
|
||||
buttons.append((btn.get("name"), f))
|
||||
|
||||
# 3. Buttons map to methods
|
||||
for name, src in buttons:
|
||||
if name not in methods:
|
||||
errors.append(f"BUTTON '{name}' has no method (in {src})")
|
||||
|
||||
# 4. Manifest data files exist
|
||||
manifest_path = os.path.join(ROOT, "__manifest__.py")
|
||||
manifest = ast.literal_eval(
|
||||
re.sub(r"^#.*", "", open(manifest_path).read(), flags=re.MULTILINE).strip()
|
||||
)
|
||||
for rel in manifest.get("data", []):
|
||||
if not os.path.exists(os.path.join(ROOT, rel)):
|
||||
errors.append(f"MANIFEST data file missing: {rel}")
|
||||
|
||||
# 5. ir.model.access.csv references defined models
|
||||
acl = os.path.join(ROOT, "security", "ir.model.access.csv")
|
||||
if os.path.exists(acl):
|
||||
with open(acl) as fh:
|
||||
for row in csv.DictReader(fh):
|
||||
mid = row["model_id:id"]
|
||||
# external models (base.*, mail.*) are fine; only check our own
|
||||
if mid.startswith("model_familylaw_") and mid not in model_tech_names:
|
||||
errors.append(f"ACL references unknown model: {mid}")
|
||||
|
||||
print(f"Checked OK: {ok_count} files | models: {len(model_names)} | "
|
||||
f"buttons: {len(buttons)}")
|
||||
if errors:
|
||||
print("\n".join("FAIL: " + e for e in errors))
|
||||
sys.exit(1)
|
||||
print("ALL STATIC CHECKS PASSED")
|
||||
Reference in New Issue
Block a user