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:
tocmo0nlord
2026-06-02 03:58:48 +00:00
parent 8bbae06b06
commit 979bbfa14a
12 changed files with 688 additions and 16 deletions

View File

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

View File

@@ -5,3 +5,4 @@ from . import familylaw_issue
from . import familylaw_proceeding
from . import familylaw_conflict
from . import familylaw_intake
from . import familylaw_document

View File

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

View File

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

View File

@@ -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 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
12 access_familylaw_conflict_hit_user familylaw.conflict.hit staff model_familylaw_conflict_hit group_familylaw_user 1 1 1 0
13 access_familylaw_conflict_hit_attorney familylaw.conflict.hit attorney model_familylaw_conflict_hit group_familylaw_attorney 1 1 1 1
14 access_familylaw_intake_wizard_user familylaw.intake.wizard user model_familylaw_intake_wizard base.group_user 1 1 1 1
15 access_familylaw_document_user familylaw.document staff model_familylaw_document group_familylaw_user 1 1 1 0
16 access_familylaw_document_attorney familylaw.document attorney model_familylaw_document group_familylaw_attorney 1 1 1 1

View File

@@ -1,2 +1,3 @@
from . import test_case_lifecycle
from . import test_step2
from . import test_step3

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
View 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")