Step 8: discovery + Rule 12.351 subpoena (objection-window gate + pro-se/attorney routing)
familylaw.discovery.request (per proceeding): - discovery_type incl. nonparty_production (Rule 12.351) - HARD objection-window gate: a non-party subpoena cannot issue before notice is served + the 10-day objection window elapses, or while an objection is pending (_ensure_objection_window_elapsed). Party discovery is not gated. - issuance_route computed from representation: attorney issues directly, pro se via the clerk (Forms & Playbook Part C) - on issue: Notice of Issuance served the SAME DAY; response due +30 days - objection_deadline (+10, weekend roll) and response_due (+30, roll) computed - VERIFY note: Rule 12.410 subpoena amendment eff. Oct 1 2025 Proceeding gets a Discovery tab; discovery views + menu + ACL. Tests (familylaw_step8, fixed dates): cannot issue before notice / before window / with pending objection; can issue after window; 10-day deadline math with weekend roll; same-day notice of issuance; attorney vs clerk routing; party production not gated; serve-notice transition; proceeding linkage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
"name": "Active Blue Family Law",
|
||||
"version": "18.0.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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -5,3 +5,4 @@ from . import test_step4
|
||||
from . import test_step5
|
||||
from . import test_step6
|
||||
from . import test_step7
|
||||
from . import test_step8
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""STEP 8 tests — discovery + Rule 12.351 subpoena (objection-window gate + routing).
|
||||
|
||||
odoo -d <db> -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)
|
||||
@@ -0,0 +1,94 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_familylaw_discovery_list" model="ir.ui.view">
|
||||
<field name="name">familylaw.discovery.request.list</field>
|
||||
<field name="model">familylaw.discovery.request</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Discovery">
|
||||
<field name="name"/>
|
||||
<field name="case_id"/>
|
||||
<field name="discovery_type"/>
|
||||
<field name="issuance_route"/>
|
||||
<field name="objection_deadline"/>
|
||||
<field name="response_due_date"/>
|
||||
<field name="state" widget="badge"
|
||||
decoration-success="state == 'responded'"
|
||||
decoration-danger="state == 'objected'"
|
||||
decoration-info="state in ('draft','notice_served')"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_familylaw_discovery_form" model="ir.ui.view">
|
||||
<field name="name">familylaw.discovery.request.form</field>
|
||||
<field name="model">familylaw.discovery.request</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Discovery Request">
|
||||
<header>
|
||||
<button name="action_serve_notice" type="object"
|
||||
string="Serve Notice (12.351)" class="btn-primary"
|
||||
invisible="not is_nonparty or state not in ('draft',)"/>
|
||||
<button name="action_record_objection" type="object"
|
||||
string="Record Objection"
|
||||
invisible="state not in ('notice_served',)"/>
|
||||
<button name="action_issue" type="object"
|
||||
string="Issue" class="btn-primary"
|
||||
invisible="state in ('issued','responded','objected')"/>
|
||||
<button name="action_mark_responded" type="object"
|
||||
string="Mark Responded"
|
||||
invisible="state != 'issued'"/>
|
||||
<field name="state" widget="statusbar"
|
||||
statusbar_visible="draft,notice_served,issued,responded"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="alert alert-warning" role="alert"
|
||||
invisible="not is_nonparty or state != 'notice_served'">
|
||||
Rule 12.351 objection window runs to
|
||||
<field name="objection_deadline" readonly="1" nolabel="1"/>.
|
||||
The non-party subpoena cannot be issued before then, or while
|
||||
an objection is pending.
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<label for="name"/>
|
||||
<h1><field name="name" placeholder="e.g. Subpoena to Employer for payroll records"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Request">
|
||||
<field name="discovery_type"/>
|
||||
<field name="is_nonparty" invisible="1"/>
|
||||
<field name="nonparty_name" invisible="not is_nonparty"/>
|
||||
<field name="proceeding_id"/>
|
||||
<field name="case_id" readonly="1"/>
|
||||
</group>
|
||||
<group string="Routing & Timing">
|
||||
<field name="representation" readonly="1"/>
|
||||
<field name="issuance_route" readonly="1"/>
|
||||
<field name="notice_served_date"/>
|
||||
<field name="objection_deadline" readonly="1"/>
|
||||
<field name="issuance_date" readonly="1"/>
|
||||
<field name="notice_of_issuance_date" readonly="1"/>
|
||||
<field name="response_due_date" readonly="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Objection">
|
||||
<field name="objection_received"/>
|
||||
<field name="objection_note"/>
|
||||
</group>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids"/>
|
||||
<field name="activity_ids"/>
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_familylaw_discovery" model="ir.actions.act_window">
|
||||
<field name="name">Discovery</field>
|
||||
<field name="res_model">familylaw.discovery.request</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -41,6 +41,13 @@
|
||||
action="action_familylaw_affidavit"
|
||||
sequence="40"/>
|
||||
|
||||
<!-- Discovery -->
|
||||
<menuitem id="menu_familylaw_discovery"
|
||||
name="Discovery"
|
||||
parent="menu_familylaw_root"
|
||||
action="action_familylaw_discovery"
|
||||
sequence="45"/>
|
||||
|
||||
<!-- Citations -->
|
||||
<menuitem id="menu_familylaw_citations"
|
||||
name="Citations"
|
||||
|
||||
@@ -112,6 +112,18 @@
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Discovery" name="discovery">
|
||||
<field name="discovery_ids"
|
||||
context="{'default_proceeding_id': id}">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="discovery_type"/>
|
||||
<field name="issuance_route"/>
|
||||
<field name="objection_deadline"/>
|
||||
<field name="state" widget="badge"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
|
||||
Reference in New Issue
Block a user