diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/__manifest__.py b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/__manifest__.py index e6f1099..886cf7b 100644 --- a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/__manifest__.py +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/__manifest__.py @@ -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", diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/__init__.py b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/__init__.py index 7e2c72e..409633c 100644 --- a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/__init__.py +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/__init__.py @@ -5,3 +5,4 @@ from . import familylaw_issue from . import familylaw_proceeding from . import familylaw_conflict from . import familylaw_intake +from . import familylaw_document diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/familylaw_document.py b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/familylaw_document.py new file mode 100644 index 0000000..9ec4501 --- /dev/null +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/familylaw_document.py @@ -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 diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/familylaw_proceeding.py b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/familylaw_proceeding.py index cd34fa4..91b6df0 100644 --- a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/familylaw_proceeding.py +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/familylaw_proceeding.py @@ -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" diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/security/ir.model.access.csv b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/security/ir.model.access.csv index 2764a8c..9651086 100644 --- a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/security/ir.model.access.csv +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/security/ir.model.access.csv @@ -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 diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/tests/__init__.py b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/tests/__init__.py index 7b2355e..a5f4e29 100644 --- a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/tests/__init__.py +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/tests/__init__.py @@ -1,2 +1,3 @@ from . import test_case_lifecycle from . import test_step2 +from . import test_step3 diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/tests/test_step3.py b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/tests/test_step3.py new file mode 100644 index 0000000..b7d332d --- /dev/null +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/tests/test_step3.py @@ -0,0 +1,160 @@ +# -*- coding: utf-8 -*- +"""STEP 3 tests — documents + THE REVIEW GATE (Gate 1). + +Run just this step: + odoo -d -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) diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_case_views.xml b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_case_views.xml index 8032c92..bb087ce 100644 --- a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_case_views.xml +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_case_views.xml @@ -102,20 +102,46 @@ - + + + + + + + + - + + + + + + + + - + + + + + + - + + + + + + + + + + diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_document_views.xml b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_document_views.xml new file mode 100644 index 0000000..65c2d54 --- /dev/null +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_document_views.xml @@ -0,0 +1,147 @@ + + + + + familylaw.document.list + familylaw.document + + + + + + + + + + + + + + familylaw.document.form + familylaw.document + +
+
+
+ + +
+
+ + + + + + + + + + + + + + + + + + +
+
+ + + +
+
+
+
+ + + familylaw.document.search + familylaw.document + + + + + + + + + + + + + + + + + + + + + Documents + familylaw.document + list,form + + + + + + familylaw.document.inline.list + familylaw.document + + + + + + + + + + +
diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_menus.xml b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_menus.xml index 8b00426..e4e1ca1 100644 --- a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_menus.xml +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_menus.xml @@ -20,6 +20,13 @@ action="action_familylaw_case" sequence="10"/> + + + + + + + + + + + + + + +
diff --git a/scripts/validate_module.py b/scripts/validate_module.py new file mode 100644 index 0000000..680b278 --- /dev/null +++ b/scripts/validate_module.py @@ -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=, ) + * 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=", "