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 dacfaee..fdb3a37 100644 --- a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/__manifest__.py +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/__manifest__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- { "name": "Active Blue Family Law", - "version": "18.0.7.0.0", + "version": "18.0.8.0.0", "category": "Services/Legal", "summary": "Florida family law case management (Miami-Dade / 11th Judicial Circuit)", "description": """ @@ -34,6 +34,7 @@ Each step adds one vertical, independently testable slice. See BUILD_PLAN.md. "views/familylaw_deadline_views.xml", "views/familylaw_disclosure_views.xml", "views/familylaw_ai_views.xml", + "views/familylaw_discovery_views.xml", "views/familylaw_proceeding_views.xml", "views/familylaw_intake_views.xml", "views/familylaw_case_views.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 5590df7..54028e1 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 @@ -12,4 +12,5 @@ from . import familylaw_ai from . import familylaw_citation from . import familylaw_verifier from . import familylaw_research +from . import familylaw_discovery from . import familylaw_ai_wizard diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/familylaw_discovery.py b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/familylaw_discovery.py new file mode 100644 index 0000000..99c1aeb --- /dev/null +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/models/familylaw_discovery.py @@ -0,0 +1,204 @@ +# -*- coding: utf-8 -*- +"""STEP 8 — Discovery + subpoena (Rule 12.351 objection-window gate + routing). + +A discovery request hangs off a PROCEEDING. The headline safety feature is the +NON-PARTY PRODUCTION (Rule 12.351) objection-window gate: + + Before a Rule 12.351 subpoena for production from a non-party may be ISSUED, the + requesting party must serve notice on the other parties and wait out the objection + window (10 days). The subpoena CANNOT be issued until that window has elapsed AND + no objection is pending. This is a HARD gate, enforced in code. + +Issuance ROUTING depends on representation (Forms & Playbook, Part C): + * attorney of record -> issues directly (officer of the court) + * pro se -> the CLERK issues the subpoena +On issuance, a Notice of Issuance must be served the SAME DAY. + +VERIFY current rule — Rule 12.410 (subpoenas) was amended effective Oct 1 2025; +confirm the objection window and notice mechanics before relying in production. +""" + +from datetime import timedelta + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError + +OBJECTION_DAYS = 10 # Rule 12.351 notice/objection window — VERIFY +RESPONSE_DAYS = 30 # typical production response window — VERIFY + + +def _roll(due): + while due.weekday() >= 5: + due += timedelta(days=1) + return due + + +class FamilyLawDiscoveryRequest(models.Model): + _name = "familylaw.discovery.request" + _description = "Discovery Request / Subpoena" + _inherit = ["mail.thread"] + _order = "create_date desc" + + name = fields.Char(required=True, tracking=True) + proceeding_id = fields.Many2one( + "familylaw.proceeding", required=True, ondelete="cascade", index=True, + tracking=True, + ) + case_id = fields.Many2one( + "familylaw.case", related="proceeding_id.case_id", store=True, index=True, + ) + representation = fields.Selection( + related="case_id.representation", store=True, string="Representation", + ) + discovery_type = fields.Selection( + selection=[ + ("interrogatories", "Interrogatories"), + ("production", "Request for Production (party)"), + ("admissions", "Requests for Admission"), + ("nonparty_production", "Non-Party Production — Rule 12.351"), + ("deposition", "Deposition"), + ], + string="Type", + required=True, + default="production", + tracking=True, + ) + is_nonparty = fields.Boolean(compute="_compute_is_nonparty", store=True) + nonparty_name = fields.Char(string="Non-Party (subpoena target)") + + issuance_route = fields.Selection( + selection=[ + ("attorney_direct", "Attorney issues directly"), + ("clerk", "Clerk issues (pro se)"), + ], + compute="_compute_issuance_route", + store=True, + string="Issuance Route", + ) + + # Rule 12.351 objection window + notice_served_date = fields.Date( + string="Notice to Parties Served", + tracking=True, + help="Starts the Rule 12.351 objection window.", + ) + objection_deadline = fields.Date( + compute="_compute_objection_deadline", store=True, + ) + objection_received = fields.Boolean(string="Objection Received", tracking=True) + objection_note = fields.Text() + + state = fields.Selection( + selection=[ + ("draft", "Draft"), + ("notice_served", "Notice Served"), + ("issued", "Issued"), + ("responded", "Responded"), + ("objected", "Objected"), + ], + default="draft", + required=True, + tracking=True, + ) + issuance_date = fields.Date(string="Issued On", readonly=True, copy=False) + notice_of_issuance_date = fields.Date( + string="Notice of Issuance Served", readonly=True, copy=False, + help="Must be served the same day the subpoena issues.", + ) + response_due_date = fields.Date(compute="_compute_response_due", store=True) + + @api.depends("discovery_type") + def _compute_is_nonparty(self): + for r in self: + r.is_nonparty = r.discovery_type == "nonparty_production" + + @api.depends("representation") + def _compute_issuance_route(self): + for r in self: + r.issuance_route = ( + "attorney_direct" if r.representation == "attorney" else "clerk" + ) + + @api.depends("notice_served_date") + def _compute_objection_deadline(self): + for r in self: + if r.notice_served_date: + r.objection_deadline = _roll( + r.notice_served_date + timedelta(days=OBJECTION_DAYS) + ) + else: + r.objection_deadline = False + + @api.depends("issuance_date") + def _compute_response_due(self): + for r in self: + if r.issuance_date: + r.response_due_date = _roll( + r.issuance_date + timedelta(days=RESPONSE_DAYS) + ) + else: + r.response_due_date = False + + # --- workflow ----------------------------------------------------------- + def action_serve_notice(self): + for r in self: + if not r.notice_served_date: + r.notice_served_date = fields.Date.context_today(r) + r.state = "notice_served" + r.message_post(body=_("Notice to other parties served (Rule 12.351). " + "Objection window runs to %s.") % r.objection_deadline) + return True + + def _ensure_objection_window_elapsed(self): + """HARD gate for Rule 12.351 non-party subpoenas.""" + today = fields.Date.context_today(self) + for r in self: + if not r.is_nonparty: + continue + if not r.notice_served_date: + raise UserError( + _("Cannot issue a Rule 12.351 non-party subpoena before serving " + "notice on the other parties and running the objection window.") + ) + if r.objection_received: + raise UserError( + _("An objection is pending on '%s'. The subpoena cannot be issued " + "until the objection is resolved by the court.") % r.name + ) + if not r.objection_deadline or today < r.objection_deadline: + raise UserError( + _("The Rule 12.351 objection window has not elapsed (runs to %s). " + "The non-party subpoena cannot be issued yet.") + % r.objection_deadline + ) + + def action_record_objection(self): + for r in self: + r.objection_received = True + r.state = "objected" + r.message_post(body=_("Objection recorded — issuance blocked pending " + "court resolution.")) + return True + + def action_issue(self): + """Issue the subpoena/request. Enforces the 12.351 objection gate for + non-party production and serves the Notice of Issuance the SAME DAY.""" + self._ensure_objection_window_elapsed() + today = fields.Date.context_today(self) + for r in self: + r.write({ + "state": "issued", + "issuance_date": today, + "notice_of_issuance_date": today, # same-day requirement + }) + route = dict(r._fields["issuance_route"].selection).get(r.issuance_route) + r.message_post(body=_("Issued (%(route)s). Notice of Issuance served " + "same day. Response due %(due)s.", + route=route, due=r.response_due_date)) + return True + + def action_mark_responded(self): + for r in self: + r.state = "responded" + r.message_post(body=_("Response received.")) + 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 dcf35fe..ac6b391 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 @@ -66,6 +66,9 @@ class FamilyLawProceeding(models.Model): affidavit_ids = fields.One2many( "familylaw.financial.affidavit", "proceeding_id", string="Financial Affidavits", ) + discovery_ids = fields.One2many( + "familylaw.discovery.request", "proceeding_id", string="Discovery", + ) def action_seed_disclosure_items(self): """Seed the standard Rule 12.285 mandatory disclosure checklist. 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 0bfd70d..b9f10f3 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 @@ -27,3 +27,5 @@ access_familylaw_ai_task_attorney,familylaw.ai.task attorney,model_familylaw_ai_ access_familylaw_citation_user,familylaw.citation staff,model_familylaw_citation,group_familylaw_user,1,1,1,0 access_familylaw_citation_attorney,familylaw.citation attorney,model_familylaw_citation,group_familylaw_attorney,1,1,1,1 access_familylaw_ai_draft_wizard_user,familylaw.ai.draft.wizard user,model_familylaw_ai_draft_wizard,group_familylaw_user,1,1,1,1 +access_familylaw_discovery_user,familylaw.discovery.request staff,model_familylaw_discovery_request,group_familylaw_user,1,1,1,0 +access_familylaw_discovery_attorney,familylaw.discovery.request attorney,model_familylaw_discovery_request,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 1ee1e62..4f9d5d0 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 @@ -5,3 +5,4 @@ from . import test_step4 from . import test_step5 from . import test_step6 from . import test_step7 +from . import test_step8 diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/tests/test_step8.py b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/tests/test_step8.py new file mode 100644 index 0000000..5d5d369 --- /dev/null +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/tests/test_step8.py @@ -0,0 +1,129 @@ +# -*- coding: utf-8 -*- +"""STEP 8 tests — discovery + Rule 12.351 subpoena (objection-window gate + routing). + + odoo -d -u activeblue_familylaw --test-enable \ + --test-tags familylaw_step8 --stop-after-init + +Objection-window math uses FIXED notice dates safely in the past/future. Proves: + * a non-party subpoena cannot issue before the objection window elapses; + * it cannot issue while an objection is pending; + * it can issue once the window has elapsed with no objection; + * issuance serves the Notice of Issuance the SAME DAY; + * issuance routing follows representation (attorney direct vs clerk); + * party discovery (non-12.351) is not blocked by the window. +""" + +from datetime import date + +from odoo.tests.common import TransactionCase, tagged +from odoo.exceptions import UserError + + +@tagged("post_install", "-at_install", "familylaw", "familylaw_step8") +class TestStep8Discovery(TransactionCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.partner = cls.env["res.partner"].create({"name": "Disc8 Client"}) + cls.case = cls.env["familylaw.case"].create({ + "name": "Discovery Matter", "client_id": cls.partner.id, + "case_type": "support_modification", "representation": "attorney", + }) + cls.proc = cls.case.proceeding_ids[0] + cls.case_prose = cls.env["familylaw.case"].create({ + "name": "Pro Se Matter", "client_id": cls.partner.id, + "case_type": "support_modification", "representation": "pro_se", + }) + cls.proc_prose = cls.case_prose.proceeding_ids[0] + cls.Req = cls.env["familylaw.discovery.request"] + + def _nonparty(self, proc=None, **kw): + vals = { + "name": "Subpoena to Employer", + "proceeding_id": (proc or self.proc).id, + "discovery_type": "nonparty_production", + "nonparty_name": "Acme Corp", + } + vals.update(kw) + return self.Req.create(vals) + + # --- objection window gate ---------------------------------------------- + def test_01_cannot_issue_before_notice(self): + req = self._nonparty() + with self.assertRaises(UserError): + req.action_issue() + + def test_02_cannot_issue_before_window_elapses(self): + # notice served in the future -> window not elapsed + req = self._nonparty(notice_served_date=date(2999, 1, 1), + state="notice_served") + with self.assertRaises(UserError): + req.action_issue() + self.assertNotEqual(req.state, "issued") + + def test_03_can_issue_after_window(self): + req = self._nonparty(notice_served_date=date(2000, 1, 1), + state="notice_served") + req.action_issue() + self.assertEqual(req.state, "issued") + + def test_04_pending_objection_blocks_issue(self): + req = self._nonparty(notice_served_date=date(2000, 1, 1), + state="notice_served") + req.action_record_objection() + self.assertEqual(req.state, "objected") + with self.assertRaises(UserError): + req.action_issue() + + # --- objection deadline math -------------------------------------------- + def test_05_objection_deadline_10_days(self): + # 2025-01-01 (Wed) + 10 = 2025-01-11 (Sat) -> 2025-01-13 (Mon) + req = self._nonparty(notice_served_date=date(2025, 1, 1)) + self.assertEqual(req.objection_deadline, date(2025, 1, 13)) + + # --- same-day notice of issuance ---------------------------------------- + def test_06_notice_of_issuance_same_day(self): + req = self._nonparty(notice_served_date=date(2000, 1, 1), + state="notice_served") + req.action_issue() + self.assertTrue(req.issuance_date) + self.assertEqual(req.notice_of_issuance_date, req.issuance_date) + + def test_07_response_due_30_days(self): + req = self._nonparty(notice_served_date=date(2000, 1, 1), + state="notice_served") + req.action_issue() + self.assertTrue(req.response_due_date) + + # --- routing ------------------------------------------------------------ + def test_08_attorney_route(self): + req = self._nonparty() + self.assertEqual(req.issuance_route, "attorney_direct") + + def test_09_prose_route_clerk(self): + req = self._nonparty(proc=self.proc_prose) + self.assertEqual(req.issuance_route, "clerk") + + # --- party discovery is not gated by 12.351 ----------------------------- + def test_10_party_production_issues_freely(self): + req = self.Req.create({ + "name": "RFP to opposing party", + "proceeding_id": self.proc.id, + "discovery_type": "production", + }) + req.action_issue() # no window required + self.assertEqual(req.state, "issued") + + # --- serve notice sets date + state ------------------------------------- + def test_11_serve_notice(self): + req = self._nonparty() + req.action_serve_notice() + self.assertEqual(req.state, "notice_served") + self.assertTrue(req.notice_served_date) + self.assertTrue(req.objection_deadline) + + # --- isolation ---------------------------------------------------------- + def test_12_discovery_listed_on_proceeding(self): + req = self._nonparty() + self.assertIn(req, self.proc.discovery_ids) diff --git a/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_discovery_views.xml b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_discovery_views.xml new file mode 100644 index 0000000..3180692 --- /dev/null +++ b/activeblue_familylaw_handoff/activeblue_familylaw_build/activeblue_familylaw/views/familylaw_discovery_views.xml @@ -0,0 +1,94 @@ + + + + + familylaw.discovery.request.list + familylaw.discovery.request + + + + + + + + + + + + + + + familylaw.discovery.request.form + familylaw.discovery.request + +
+
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+
+
+
+ + + Discovery + familylaw.discovery.request + list,form + + +
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 ea736ea..2ee33af 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 @@ -41,6 +41,13 @@ action="action_familylaw_affidavit" sequence="40"/> + + + + + + + + + + + + + +