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:
tocmo0nlord
2026-06-02 04:16:25 +00:00
parent f8029eafa4
commit 83f970a031
10 changed files with 455 additions and 1 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
27 access_familylaw_citation_user familylaw.citation staff model_familylaw_citation group_familylaw_user 1 1 1 0
28 access_familylaw_citation_attorney familylaw.citation attorney model_familylaw_citation group_familylaw_attorney 1 1 1 1
29 access_familylaw_ai_draft_wizard_user familylaw.ai.draft.wizard user model_familylaw_ai_draft_wizard group_familylaw_user 1 1 1 1
30 access_familylaw_discovery_user familylaw.discovery.request staff model_familylaw_discovery_request group_familylaw_user 1 1 1 0
31 access_familylaw_discovery_attorney familylaw.discovery.request attorney model_familylaw_discovery_request group_familylaw_attorney 1 1 1 1

View File

@@ -5,3 +5,4 @@ from . import test_step4
from . import test_step5
from . import test_step6
from . import test_step7
from . import test_step8

View File

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

View File

@@ -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 &amp; 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>

View File

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

View File

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